Skip to content

关于webpack的性能优化总结

本文梳理使用 Webpack 构建项目工程时,遇到编译过程耗时长,打包慢的问题的解决方法。

缩小文件搜索范围

Webpack 启动后会从配置的 Entry 出发,解析出⽂件中的导⼊语句,再递归的解析。 在遇到导⼊语句时 Webpack 会做两件事情:

  1. 根据导⼊语句去寻找对应的要导⼊的⽂件。例如 require('react') 导⼊语句对应的⽂件是 ./node_modules/react/react.js,require('./util') 对应的⽂件是 ./util.js ;
  2. 根据找到的要导⼊⽂件的后缀,使⽤配置中的 Loader 去处理⽂件。例如使⽤ ES6 开发的 JavaScript ⽂件需要使⽤ babel-loader 去处理;

以上两件事情虽然对于处理⼀个⽂件⾮常快,但是当项⽬⼤了以后⽂件量会变的⾮常多,这时候构建速度慢的问题就会暴露出来。 虽然以上两件事情⽆法避免,但需要尽量减少以上两件事情的发⽣,以提⾼速度。

优化 loader 配置

由于 Loader 对⽂件的转换操作很耗时,需要让尽可能少的⽂件被 Loader 处理。 可以适当的调整项⽬的⽬录结构,以⽅便在配置 Loader 时通过 include 去缩⼩命中范围。

在使⽤ Loader 时可以通过 test 、 include 、 exclude 三个配置项来命中 Loader 要应⽤规则的⽂件。 为了尽可能少的让⽂件被 Loader 处理,可以通过 include 去命中只有哪些⽂件需要被处理。

以采⽤ ES6 的项⽬为例,在配置 babel-loader 时,可以这样:

js
module.exports = {
  module: {
    rules: [
      {
        // 如果项⽬源码中只有 js ⽂件就不要写成 /\.jsx?$/,提升正则表达式性能
        test: /\.js$/,
        // babel-loader ⽀持缓存转换出的结果,通过 cacheDirectory 选项开启
        use: ['babel-loader?cacheDirectory'],
        // 只对项⽬根⽬录下的 src ⽬录中的⽂件采⽤ babel-loader
        include: path.resolve(__dirname, 'src'),
      },
    ],
  },
};
module.exports = {
  module: {
    rules: [
      {
        // 如果项⽬源码中只有 js ⽂件就不要写成 /\.jsx?$/,提升正则表达式性能
        test: /\.js$/,
        // babel-loader ⽀持缓存转换出的结果,通过 cacheDirectory 选项开启
        use: ['babel-loader?cacheDirectory'],
        // 只对项⽬根⽬录下的 src ⽬录中的⽂件采⽤ babel-loader
        include: path.resolve(__dirname, 'src'),
      },
    ],
  },
};

优化 resolve.modules 配置

resolve.modules 的默认值是 ['node_modules'],含义是先去当前⽬录下的 ./node_modules ⽬录下去找想找的模块,如果没找到就去上⼀级⽬录。 ./node_modules 中找,再没有就去 ../../node_modules 中找,以此类推,这和 Node.js 的模块寻找机制很相似。 当安装的第三⽅模块都放在项⽬根⽬录下的 ./node_modules ⽬录下时,没有必要按照默认的⽅式去⼀层层的寻找,可以指明存放第三⽅模块的绝对路径,以减少寻找,配置如下:

js
module.exports = {
  resolve: {
  // 使⽤绝对路径指明第三⽅模块存放的位置,以减少搜索步骤
  // 其中 __dirname 表示当前⼯作⽬录,也就是项⽬根⽬录
    modules: [path.resolve(__dirname, 'node_modules')],
  },
};
module.exports = {
  resolve: {
  // 使⽤绝对路径指明第三⽅模块存放的位置,以减少搜索步骤
  // 其中 __dirname 表示当前⼯作⽬录,也就是项⽬根⽬录
    modules: [path.resolve(__dirname, 'node_modules')],
  },
};

优化 resolve.mainFields 配置

resolve.mainFields ⽤于配置第三⽅模块使⽤哪个⼊⼝⽂件。 安装的第三⽅模块中都会有⼀个 package.json ⽂件⽤于描述这个模块的属性,其中有些字段⽤于描述⼊⼝⽂件在哪⾥, resolve.mainFields ⽤于配置采⽤哪个字段作为⼊⼝⽂件的描述。

可以存在多个字段描述⼊⼝⽂件的原因是因为有些模块可以同时⽤在多个环境中,针对不同的运⾏环境需要使⽤不同的代码。 以 isomorphic-fetch 为例,它是 fetch API 的⼀个实现,但可同时⽤于浏览器 和 Node.js 环境。 它的 package.json 中就有2个⼊⼝⽂件描述字段:

json
{
  "browser": "fetch-npm-browserify.js",
  "main": "fetch-npm-node.js"
}
{
  "browser": "fetch-npm-browserify.js",
  "main": "fetch-npm-node.js"
}

isomorphic-fetch 在不同的运⾏环境下使⽤不同的代码是因为 fetch API 的实现机制不⼀样,在浏览器中通过原⽣的fetch 或者 XMLHttpRequest 实现,在 Node.js 中通过 http 模块实现。

resolve.mainFields 的默认值和当前的 target 配置有关系,对应关系如下:

  • 当 target 为 web 或者 WebWorker 时,值是 ["browser", "module", "main"]
  • 当 target 为其它情况时,值是 ["module", "main"]

以 target 等于 web 为例,Webpack 会先采⽤第三⽅模块中的 browser 字段去寻找模块的⼊⼝⽂件,如果不存在就采⽤ module 字段,以此类推。

为了减少搜索步骤,在你明确第三⽅模块的⼊⼝⽂件描述字段时,你可以把它设置的尽量少。 由于⼤多数第三⽅模块都采⽤ main 字段去描述⼊⼝⽂件的位置,可以这样配置 Webpack:

js
module.exports = {
  resolve: {
    // 只采⽤ main 字段作为⼊⼝⽂件描述字段,以减少搜索步骤
    mainFields: ['main'],
  },
};
module.exports = {
  resolve: {
    // 只采⽤ main 字段作为⼊⼝⽂件描述字段,以减少搜索步骤
    mainFields: ['main'],
  },
};

使⽤本⽅法优化时,需要考虑到所有运⾏时依赖的第三⽅模块的⼊⼝⽂件描述字段,就算有⼀个模块搞错了都可能会造成构建出的代码⽆法正常运⾏

优化 resolve.alias 配置

resolve.alias 配置项通过别名来把原导⼊路径映射成⼀个新的导⼊路径。

在实战项⽬中经常会依赖⼀些庞⼤的第三⽅模块,以 React 库为例,安装到 node_modules ⽬录下的 React 库的⽬录结构如下:

bash
├── dist
 ├── react.js
 └── react.min.js
├── lib
 ... 还有⼏⼗个⽂件被忽略
 ├── LinkedStateMixin.js
 ├── createClass.js
 └── React.js
├── package.json
└── react.js
├── dist
 ├── react.js
 └── react.min.js
├── lib
 ... 还有⼏⼗个⽂件被忽略
 ├── LinkedStateMixin.js
 ├── createClass.js
 └── React.js
├── package.json
└── react.js

可以看到发布出去的 React 库中包含两套代码:

  • ⼀套是采⽤ CommonJS 规范的模块化代码,这些⽂件都放在 lib ⽬录下,以 package.json 中指定的⼊⼝⽂件 react.js 为模块的⼊⼝。
  • ⼀套是把 React 所有相关的代码打包好的完整代码放到⼀个单独的⽂件中,这些代码没有采⽤模块化可以直接执⾏。其中 dist/react.js 是⽤于开发环境,⾥⾯包含检查和警告的代码。 dist/react.min.js 是⽤于线上环境,被最⼩化了。

默认情况下 Webpack 会从⼊⼝⽂件 ./node_modules/react/react.js 开始递归的解析和处理依赖的⼏⼗个⽂件,这会时⼀个耗时的操作。 通过配置 resolve.alias 可以让 Webpack 在处理 React 库时,直接使⽤单独完整的 react.min.js ⽂件,从⽽跳过耗时的递归解析操作。

js
module.exports = {
  resolve: {
  // 使⽤ alias 把导⼊ react 的语句换成直接使⽤单独完整的 react.min.js ⽂件,
  // 减少耗时的递归解析操作
    alias: {
      // react: path.resolve(__dirname, './node_modules/react/dist/react.min.js'), // react15
      'react': path.resolve(__dirname, './node_modules/react/umd/react.production.min.js'), // react16
    },
  },
};
module.exports = {
  resolve: {
  // 使⽤ alias 把导⼊ react 的语句换成直接使⽤单独完整的 react.min.js ⽂件,
  // 减少耗时的递归解析操作
    alias: {
      // react: path.resolve(__dirname, './node_modules/react/dist/react.min.js'), // react15
      'react': path.resolve(__dirname, './node_modules/react/umd/react.production.min.js'), // react16
    },
  },
};

除了 React 库外,⼤多数库发布到 Npm 仓库中时都会包含打包好的完整⽂件,对于这些库你也可以对它们配置 alias。但是对于有些库使⽤本优化⽅法后会影响到后⾯要讲的使⽤ Tree-Shaking 去除⽆效代码的优化,因为打包好的完整⽂件中有部分代码你的项⽬可能永远⽤不上。 ⼀般对整体性⽐较强的库采⽤本⽅法优,因为完整⽂件中的代码是⼀个整体,每⼀⾏都是不可或缺的。 但是对于⼀些⼯具类的库,例如 lodash,你的项⽬可能只⽤到了其中⼏个⼯具函数,你就不能使⽤本⽅法去优化,因为这会导致你的输出代码中包含很多永远不会执⾏的代码。

优化 resolve.extensions 配置

在导⼊语句没带⽂件后缀时,Webpack 会⾃动带上后缀后去尝试询问⽂件是否存在。 resolve.extensions ⽤于配置在尝试过程中⽤到的后缀列表,默认是:

js
extensions: ['.js', '.json']
extensions: ['.js', '.json']

也就是说当遇到 require('./data') 这样的导⼊语句时,Webpack 会先去寻找 ./data.js ⽂件,如果该⽂件不存在就去寻找 ./data.json ⽂件,如果还是找不到就报错。 如果这个列表越⻓,或者正确的后缀在越后⾯,就会造成尝试的次数越多,所以 resolve.extensions 的配置也会影响到构建的性能。 在配置 resolve.extensions 时需要遵守以下⼏点,以做到尽可能的优化构建性能:

  • 后缀尝试列表要尽可能的⼩,不要把项⽬中不可能存在的情况写到后缀尝试列表中;
  • 频率出现最⾼的⽂件后缀要优先放在最前⾯,以做到尽快的退出寻找过程;
  • 在源码中写导⼊语句时,要尽可能的带上后缀,从⽽可以避免寻找过程。例如在你确定的情况下把 require('./data') 写成 require('./data.json') ;

相关 Webpack 配置如下:

js
module.exports = {
  resolve: {
  // 尽可能的减少后缀尝试的可能性
    extensions: ['js'],
  },
};
module.exports = {
  resolve: {
  // 尽可能的减少后缀尝试的可能性
    extensions: ['js'],
  },
};

优化 module.noParse 配置

module.noParse 配置项可以让 Webpack 忽略对部分没采⽤模块化的⽂件的递归解析处理,这样做的好处是能提⾼构建性能。 原因是⼀些库,例如 jQuery 、ChartJS, 它们庞⼤⼜没有采⽤模块化标准,让 Webpack 去解析这些⽂件耗时⼜没有意义。

在上⾯的 优化 resolve.alias 配置 中讲到单独完整的 react.min.js ⽂件就没有采⽤模块化,让我们来通过配置 module.noParse 忽略对 react.min.js ⽂件的递归解析处理, 相关 Webpack 配置如下:

js
module.exports = {
  module: {
    // 完整的 `react.min.js` ⽂件就没有采⽤模块化,忽略对 `react.min.js` ⽂件的递归解析处理
    noParse: [/react\.min\.js$/],
  },
};
module.exports = {
  module: {
    // 完整的 `react.min.js` ⽂件就没有采⽤模块化,忽略对 `react.min.js` ⽂件的递归解析处理
    noParse: [/react\.min\.js$/],
  },
};

注意被忽略掉的⽂件⾥不应该包含 import 、 require 、 define 等模块化语句,不然会导致构建出的代码中包含⽆法在浏览器环境下执⾏的模块化语句。

使用 DLLPlugin

什么是DLL

在介绍 DllPlugin 前先给⼤家介绍下 DLL。 ⽤过 Windows 系统的⼈应该会经常看到以 .dll 为后缀的⽂件,这些⽂件称为动态链接库,在⼀个动态链接库中可以包含给其他模块调⽤的函数和数据。 要给 Web 项⽬构建接⼊动态链接库的思想,需要完成以下事情:

  • 把⽹⻚依赖的基础模块抽离出来,打包到⼀个个单独的动态链接库中去。⼀个动态链接库中可以包含多个模块;
  • 当需要导⼊的模块存在于某个动态链接库中时,这个模块不能被再次被打包,⽽是去动态链接库中获取;
  • ⻚⾯依赖的所有动态链接库需要被加载;

为什么给 Web 项⽬构建接⼊动态链接库的思想后,会⼤⼤提升构建速度呢? 原因在于包含⼤量复⽤模块的动态链接库只需要编译⼀次,在之后的构建过程中被动态链接库包含的模块将不会在重新编译,⽽是直接使⽤动态链接库中的代码。 由于动态链接库中⼤多数包含的是常⽤的第三⽅模块,例如 react 、 react-dom ,只要不升级这些模块的版本,动态链接库就不⽤重新编译。

接入 webpack

Webpack 已经内置了对动态链接库的⽀持,需要通过2个内置的插件接⼊,它们分别是:

  • DllPlugin 插件:⽤于打包出⼀个个单独的动态链接库⽂件;
  • DllReferencePlugin 插件:⽤于在主要配置⽂件中去引⼊ DllPlugin 插件打包好的动态链接库⽂件;

下⾯以基本的 React 项⽬为例,为其接⼊ DllPlugin,在开始前先来看下最终构建出的⽬录结构:

bash
├── main.js
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json
├── main.js
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json

其中包含两个动态链接库⽂件,分别是:

  • polyfill.dll.js ⾥⾯包含项⽬所有依赖的 polyfill,例如 Promise、fetch 等 API;
  • react.dll.js ⾥⾯包含 React 的基础运⾏环境,也就是 react 和 react-dom 模块;

以 react.dll.js ⽂件为例,其⽂件内容⼤致如下:

js
const _dll_react = (function (modules) {
  // ... 此处省略 webpackBootstrap 函数代码
}([
  function (module, exports, __webpack_require__) {
  // 模块 ID 为 0 的模块对应的代码
  },
  function (module, exports, __webpack_require__) {
  // 模块 ID 为 1 的模块对应的代码
  },
  // ... 此处省略剩下的模块对应的代码
]));
const _dll_react = (function (modules) {
  // ... 此处省略 webpackBootstrap 函数代码
}([
  function (module, exports, __webpack_require__) {
  // 模块 ID 为 0 的模块对应的代码
  },
  function (module, exports, __webpack_require__) {
  // 模块 ID 为 1 的模块对应的代码
  },
  // ... 此处省略剩下的模块对应的代码
]));

可⻅⼀个动态链接库⽂件中包含了⼤量模块的代码,这些模块存放在⼀个数组⾥,⽤数组的索引号作为ID。 并且还通过 _dll_react 变量把⾃⼰暴露在了全局中,也就是可以通过 window._dll_react 可以访问到它⾥⾯包含的模块。

其中 polyfill.manifest.json 和 react.manifest.json ⽂件也是由 DllPlugin ⽣成出,⽤于描述动态链接库⽂件中包含哪些模块, 以 react.manifest.json ⽂件为例,其⽂件内容⼤致如下:

json
{
  // 描述该动态链接库⽂件暴露在全局的变量名称
  "name": "_dll_react",
  "content": {
    "./node_modules/process/browser.js": {
      "id": 0,
      "meta": {}
    },
    // ... 此处省略部分模块
    "./node_modules/react-dom/lib/ReactBrowserEventEmitter.js": {
      "id": 42,
      "meta": {}
    },
    "./node_modules/react/lib/lowPriorityWarning.js": {
      "id": 47,
      "meta": {}
    },
    // ... 此处省略部分模块
    "./node_modules/react-dom/lib/SyntheticTouchEvent.js": {
      "id": 210,
      "meta": {}
    },
    "./node_modules/react-dom/lib/SyntheticTransitionEvent.js": {
      "id": 211,
      "meta": {}
    },
  }
}
{
  // 描述该动态链接库⽂件暴露在全局的变量名称
  "name": "_dll_react",
  "content": {
    "./node_modules/process/browser.js": {
      "id": 0,
      "meta": {}
    },
    // ... 此处省略部分模块
    "./node_modules/react-dom/lib/ReactBrowserEventEmitter.js": {
      "id": 42,
      "meta": {}
    },
    "./node_modules/react/lib/lowPriorityWarning.js": {
      "id": 47,
      "meta": {}
    },
    // ... 此处省略部分模块
    "./node_modules/react-dom/lib/SyntheticTouchEvent.js": {
      "id": 210,
      "meta": {}
    },
    "./node_modules/react-dom/lib/SyntheticTransitionEvent.js": {
      "id": 211,
      "meta": {}
    },
  }
}

可⻅ manifest.json ⽂件清楚地描述了与其对应的 dll.js ⽂件中包含了哪些模块,以及每个模块的路径和 ID。

main.js ⽂件是编译出来的执⾏⼊⼝⽂件,当遇到其依赖的模块在 dll.js ⽂件中时,会直接通过 dll.js ⽂件暴露出的全局变量去获取打包在 dll.js ⽂件的模块。 所以在 index.html ⽂件中需要把依赖的两个 dll.js ⽂件给加载进去,index.html 内容如下:

html
<html>
<head>
  <meta charset="UTF-8">
</head>
<body>
<div id="app"></div>
  <!--导⼊依赖的动态链接库⽂件-->
  <script src="./dist/polyfill.dll.js"></script>
  <script src="./dist/react.dll.js"></script>
  <!--导⼊执⾏⼊⼝⽂件-->
  <script src="./dist/main.js"></script>
</body>
</html>
<html>
<head>
  <meta charset="UTF-8">
</head>
<body>
<div id="app"></div>
  <!--导⼊依赖的动态链接库⽂件-->
  <script src="./dist/polyfill.dll.js"></script>
  <script src="./dist/react.dll.js"></script>
  <!--导⼊执⾏⼊⼝⽂件-->
  <script src="./dist/main.js"></script>
</body>
</html>

构建出动态链接库⽂件

构建输出的以下这四个⽂件

bash
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json

和下面的 main.js 文件

bash
├── main.js
├── main.js

是由两份不同的构建分别输出的。

动态链接库⽂件相关的⽂件需要由⼀份独⽴的构建输出,⽤于给主构建使⽤。 新建⼀个 Webpack 配置⽂件 webpack_dll.config.js 专⻔⽤于构建它们,⽂件内容如下:

js
const path = require('path');
const DllPlugin = require('webpack/lib/DllPlugin');

module.exports = {
  // JS 执⾏⼊⼝⽂件
  entry: {
    // 把 React 相关模块的放到⼀个单独的动态链接库
    react: ['react', 'react-dom'],
    // 把项⽬需要所有的 polyfill 放到⼀个单独的动态链接库
    polyfill: ['core-js/fn/object/assign', 'core-js/fn/promise', 'whatwg-fetch'],
  },
  output: {
    // 输出的动态链接库的⽂件名称,[name] 代表当前动态链接库的名称,
    // 也就是 entry 中配置的 react 和 polyfill
    filename: '[name].dll.js',
    // 输出的⽂件都放到 dist ⽬录下
    path: path.resolve(__dirname, 'dist'),
    // 存放动态链接库的全局变量名称,例如对应 react 来说就是 _dll_react
    // 之所以在前⾯加上 _dll_ 是为了防⽌全局变量冲突
    library: '_dll_[name]',
  },
  plugins: [
    // 接⼊ DllPlugin
    new DllPlugin({
      // 动态链接库的全局变量名称,需要和 output.library 中保持⼀致
      // 该字段的值也就是输出的 manifest.json ⽂件 中 name 字段的值
      // 例如 react.manifest.json 中就有 "name": "_dll_react"
      name: '_dll_[name]',
      // 描述动态链接库的 manifest.json ⽂件输出时的⽂件名称
      path: path.join(__dirname, 'dist', '[name].manifest.json'),
    }),
  ],
};
const path = require('path');
const DllPlugin = require('webpack/lib/DllPlugin');

module.exports = {
  // JS 执⾏⼊⼝⽂件
  entry: {
    // 把 React 相关模块的放到⼀个单独的动态链接库
    react: ['react', 'react-dom'],
    // 把项⽬需要所有的 polyfill 放到⼀个单独的动态链接库
    polyfill: ['core-js/fn/object/assign', 'core-js/fn/promise', 'whatwg-fetch'],
  },
  output: {
    // 输出的动态链接库的⽂件名称,[name] 代表当前动态链接库的名称,
    // 也就是 entry 中配置的 react 和 polyfill
    filename: '[name].dll.js',
    // 输出的⽂件都放到 dist ⽬录下
    path: path.resolve(__dirname, 'dist'),
    // 存放动态链接库的全局变量名称,例如对应 react 来说就是 _dll_react
    // 之所以在前⾯加上 _dll_ 是为了防⽌全局变量冲突
    library: '_dll_[name]',
  },
  plugins: [
    // 接⼊ DllPlugin
    new DllPlugin({
      // 动态链接库的全局变量名称,需要和 output.library 中保持⼀致
      // 该字段的值也就是输出的 manifest.json ⽂件 中 name 字段的值
      // 例如 react.manifest.json 中就有 "name": "_dll_react"
      name: '_dll_[name]',
      // 描述动态链接库的 manifest.json ⽂件输出时的⽂件名称
      path: path.join(__dirname, 'dist', '[name].manifest.json'),
    }),
  ],
};

使⽤动态链接库⽂件

构建出的动态链接库⽂件⽤于给其它地⽅使⽤,在这⾥也就是给执⾏⼊⼝使⽤。 ⽤于输出 main.js 的主 Webpack 配置⽂件内容如下:

js
const path = require('path');
const DllReferencePlugin = require('webpack/lib/DllReferencePlugin');

module.exports = {
  entry: {
    // 定义⼊⼝ Chunk
    main: './main.js',
  },
  output: {
    // 输出⽂件的名称
    filename: '[name].js',
    // 输出⽂件都放到 dist ⽬录下
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        // 项⽬源码使⽤了 ES6 和 JSX 语法,需要使⽤ babel-loader 转换
        test: /\.js$/,
        use: ['babel-loader'],
        exclude: path.resolve(__dirname, 'node_modules'),
      },
    ],
  },
  plugins: [
    // 告诉 Webpack 使⽤了哪些动态链接库
    new DllReferencePlugin({
      // 描述 react 动态链接库的⽂件内容
      manifest: require('./dist/react.manifest.json'),
    }),
    new DllReferencePlugin({
      // 描述 polyfill 动态链接库的⽂件内容
      manifest: require('./dist/polyfill.manifest.json'),
    }),
  ],
  devtool: 'source-map',
};
const path = require('path');
const DllReferencePlugin = require('webpack/lib/DllReferencePlugin');

module.exports = {
  entry: {
    // 定义⼊⼝ Chunk
    main: './main.js',
  },
  output: {
    // 输出⽂件的名称
    filename: '[name].js',
    // 输出⽂件都放到 dist ⽬录下
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        // 项⽬源码使⽤了 ES6 和 JSX 语法,需要使⽤ babel-loader 转换
        test: /\.js$/,
        use: ['babel-loader'],
        exclude: path.resolve(__dirname, 'node_modules'),
      },
    ],
  },
  plugins: [
    // 告诉 Webpack 使⽤了哪些动态链接库
    new DllReferencePlugin({
      // 描述 react 动态链接库的⽂件内容
      manifest: require('./dist/react.manifest.json'),
    }),
    new DllReferencePlugin({
      // 描述 polyfill 动态链接库的⽂件内容
      manifest: require('./dist/polyfill.manifest.json'),
    }),
  ],
  devtool: 'source-map',
};

注意:在 webpack_dll.config.js ⽂件中, DllPlugin 中的 name 参数必须和 output.library 中保持⼀致。 原因在于DllPlugin 中的 name 参数会影响输出的 manifest.json⽂件中 name 字段的值, ⽽在 webpack.config.js ⽂件中 DllReferencePlugin 会去 manifest.json ⽂件读取 name 字段的值, 把值的内容作为在从全局变量中获取动态链接库中内容时的全局变量名。

执⾏构建

在修改好以上两个 Webpack 配置⽂件后,需要重新执⾏构建。 重新执⾏构建时要注意的是需要先把动态链接库相关的⽂件编译出来,因为主 Webpack 配置⽂件中定义的 DllReferencePlugin 依赖这些⽂件。

执⾏构建时流程如下:

  1. 如果动态链接库相关的⽂件还没有编译出来,就需要先把它们编译出来。⽅法是执⾏ webpack --config webpack_dll.config.js 命令;
  2. 在确保动态链接库存在时,才能正常的编译出⼊⼝执⾏⽂件。⽅法是执⾏ webpack 命令。这时你会发现构建速度有了⾮常⼤的提升。

使用 HappyPack

由于有⼤量⽂件需要解析和处理,构建是⽂件读写和计算密集型的操作,特别是当⽂件数量变多后,Webpack 构建慢的问题会显得严重。 运⾏在 Node.js 之上的 Webpack 是单线程模型的,也就是说 Webpack 需要处理的任务需要⼀件件挨着做,不能多个事情⼀起做。

⽂件读写和计算操作是⽆法避免的,那能不能让 Webpack 同⼀时刻处理多个任务,发挥多核 CPU 电脑的威⼒,以提升构建速度呢?

HappyPack 就能让 Webpack 做到这点,它把任务分解给多个⼦进程去并发的执⾏,⼦进程处理完后再把结果发送给主进程。 由于 JavaScript 是单线程模型,要想发挥多核 CPU 的能⼒,只能通过多进程去实现,⽽⽆法通过多线程实现。

使⽤ HappyPack

分解任务和管理线程的事情 HappyPack 都会帮你做好,你所需要做的只是接⼊ HappyPack。 接⼊ HappyPack 的相关代码如下:

js
const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const HappyPack = require('happypack');

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        // 把对 .js ⽂件的处理转交给 id 为 babel 的 HappyPack 实例
        use: ['happypack/loader?id=babel'],
        // 排除 node_modules ⽬录下的⽂件,node_modules ⽬录下的⽂件都是采⽤的 ES5 语法,没必要再通过 Babel 去转换
        exclude: path.resolve(__dirname, 'node_modules'),
      },
      {
        // 把对 .css ⽂件的处理转交给 id 为 css 的 HappyPack 实例
        test: /\.css$/,
        use: ExtractTextPlugin.extract({
          use: ['happypack/loader?id=css'],
        }),
      },
    ],
  },
  plugins: [
    new HappyPack({
      // ⽤唯⼀的标识符 id 来代表当前的 HappyPack 是⽤来处理⼀类特定的⽂件
      id: 'babel',
      // 如何处理 .js ⽂件,⽤法和 Loader 配置中⼀样
      loaders: ['babel-loader?cacheDirectory'],
      // ... 其它配置项
    }),
    new HappyPack({
      id: 'css',
      // 如何处理 .css ⽂件,⽤法和 Loader 配置中⼀样
      loaders: ['css-loader'],
    }),
    new ExtractTextPlugin({
      filename: '[name].css',
    }),
  ],
};
const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const HappyPack = require('happypack');

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        // 把对 .js ⽂件的处理转交给 id 为 babel 的 HappyPack 实例
        use: ['happypack/loader?id=babel'],
        // 排除 node_modules ⽬录下的⽂件,node_modules ⽬录下的⽂件都是采⽤的 ES5 语法,没必要再通过 Babel 去转换
        exclude: path.resolve(__dirname, 'node_modules'),
      },
      {
        // 把对 .css ⽂件的处理转交给 id 为 css 的 HappyPack 实例
        test: /\.css$/,
        use: ExtractTextPlugin.extract({
          use: ['happypack/loader?id=css'],
        }),
      },
    ],
  },
  plugins: [
    new HappyPack({
      // ⽤唯⼀的标识符 id 来代表当前的 HappyPack 是⽤来处理⼀类特定的⽂件
      id: 'babel',
      // 如何处理 .js ⽂件,⽤法和 Loader 配置中⼀样
      loaders: ['babel-loader?cacheDirectory'],
      // ... 其它配置项
    }),
    new HappyPack({
      id: 'css',
      // 如何处理 .css ⽂件,⽤法和 Loader 配置中⼀样
      loaders: ['css-loader'],
    }),
    new ExtractTextPlugin({
      filename: '[name].css',
    }),
  ],
};

以上代码有两点重要的修改:

  • 在 Loader 配置中,所有⽂件的处理都交给了 happypack/loader 去处理,使⽤紧跟其后的 querystring?id=babel 去告诉 happypack/loader 去选择哪个 HappyPack 实例去处理⽂ 件;
  • 在 Plugin 配置中,新增了两个 HappyPack 实例分别⽤于告诉 happypack/loader 去如何处理 .js 和 .css ⽂件。选项中的 id 属性的值和上⾯ querystring 中的 ?id=babel 相对应,选项中的 loaders 属性和 Loader 配置中⼀样;

在实例化 HappyPack 插件的时候,除了可以传⼊ id 和 loaders 两个参数外,HappyPack 还⽀持如下参数:

  • threads 代表开启⼏个⼦进程去处理这⼀类型的⽂件,默认是3个,类型必须是整数;
  • verbose 是否允许 HappyPack 输出⽇志,默认是 true;
  • threadPool 代表共享进程池,即多个 HappyPack 实例都使⽤同⼀个共享进程池中的⼦进程去处理任务,以防⽌资源占⽤过多,相关代码如下
js
const HappyPack = require('happypack');
// 构造出共享进程池,进程池中包含5个⼦进程
const happyThreadPool = HappyPack.ThreadPool({ size: 5 });
module.exports = {
  plugins: [
    new HappyPack({
      // ⽤唯⼀的标识符 id 来代表当前的 HappyPack 是⽤来处理⼀类特定的⽂件
      id: 'babel',
      // 如何处理 .js ⽂件,⽤法和 Loader 配置中⼀样
      loaders: ['babel-loader?cacheDirectory'],
      // 使⽤共享进程池中的⼦进程去处理任务
      threadPool: happyThreadPool,
    }),
    new HappyPack({
      id: 'css',
      // 如何处理 .css ⽂件,⽤法和 Loader 配置中⼀样
      loaders: ['css-loader'],
      // 使⽤共享进程池中的⼦进程去处理任务
      threadPool: happyThreadPool,
    }),
    new ExtractTextPlugin({
      filename: '[name].css',
    }),
  ],
};
const HappyPack = require('happypack');
// 构造出共享进程池,进程池中包含5个⼦进程
const happyThreadPool = HappyPack.ThreadPool({ size: 5 });
module.exports = {
  plugins: [
    new HappyPack({
      // ⽤唯⼀的标识符 id 来代表当前的 HappyPack 是⽤来处理⼀类特定的⽂件
      id: 'babel',
      // 如何处理 .js ⽂件,⽤法和 Loader 配置中⼀样
      loaders: ['babel-loader?cacheDirectory'],
      // 使⽤共享进程池中的⼦进程去处理任务
      threadPool: happyThreadPool,
    }),
    new HappyPack({
      id: 'css',
      // 如何处理 .css ⽂件,⽤法和 Loader 配置中⼀样
      loaders: ['css-loader'],
      // 使⽤共享进程池中的⼦进程去处理任务
      threadPool: happyThreadPool,
    }),
    new ExtractTextPlugin({
      filename: '[name].css',
    }),
  ],
};

接⼊ HappyPack 后,你需要给项⽬安装新的依赖:

bash
npm i -D happypack
npm i -D happypack

安装成功后重新执⾏构建就会看到以下由 HappyPack 输出的⽇志:

bash
Happy[babel]: Version: 4.0.0-beta.5. Threads: 3
Happy[babel]: All set; signaling webpack to proceed.
Happy[css]: Version: 4.0.0-beta.5. Threads: 3
Happy[css]: All set; signaling webpack to proceed.
Happy[babel]: Version: 4.0.0-beta.5. Threads: 3
Happy[babel]: All set; signaling webpack to proceed.
Happy[css]: Version: 4.0.0-beta.5. Threads: 3
Happy[css]: All set; signaling webpack to proceed.

说明你的 HappyPack 配置⽣效了,并且可以得知 HappyPack 分别启动了3个⼦进程去并⾏的处理任务。

HappyPack 原理

在整个 Webpack 构建流程中,最耗时的流程可能就是 Loader 对⽂件的转换操作了,因为要转换的⽂件数据巨多,⽽且这些转换操作都只能⼀个个挨着处理。 HappyPack 的核⼼原理就是把这部分任务分解到多个进程去并⾏处理,从⽽减少了总的构建时间。

从前⾯的使⽤中可以看出所有需要通过 Loader 处理的⽂件都先交给了 happypack/loader 去处理,收集到了这些⽂件的处理权后 HappyPack 就好统⼀分配了。

每通过 new HappyPack() 实例化⼀个 HappyPack 其实就是告诉 HappyPack 核⼼调度器如何通过⼀系列 Loader 去转换⼀类⽂件,并且可以指定如何给这类转换操作分配⼦进程。

核⼼调度器的逻辑代码在主进程中,也就是运⾏着 Webpack 的进程中,核⼼调度器会把⼀个个任务分配给当前空闲的⼦进程,⼦进程处理完毕后把结果发送给核⼼调度器,它们之间的数据交换是通过进程间通信 API 实现的。

核⼼调度器收到来⾃⼦进程处理完毕的结果后会通知 Webpack 该⽂件处理完毕。

使⽤ ParallelUglifyPlugin

在使⽤ Webpack 构建出⽤于发布到线上的代码时,都会有压缩代码这⼀流程。 最常⻅的 JavaScript 代码压缩⼯具是 UglifyJS,并且 Webpack 也内置了它。

⽤过 UglifyJS 的你⼀定会发现在构建⽤于开发环境的代码时很快就能完成,但在构建⽤于线上的代码时构建⼀直卡在⼀个时间点迟迟没有反应,其实卡住的这个时候就是在进⾏代码压缩。

由于压缩 JavaScript 代码需要先把代码解析成⽤ Object 抽象表示的 AST 语法树,再去应⽤各种规则分析和处理 AST,导致这个过程计算量巨⼤,耗时⾮常多。

这⾥可以使⽤多进程并⾏处理的思想也引⼊到代码压缩。ParallelUglifyPlugin 就做了这个事情。 当Webpack 有多个 JavaScript ⽂件需要输出和压缩时,原本会使⽤ UglifyJS 去⼀个个挨着压缩再输出, 但是 ParallelUglifyPlugin 则会开启多个⼦进程,把对多个⽂件的压缩⼯作分配给多个⼦进程去完成,每个⼦进程其实还是通过 UglifyJS 去压缩代码,但是变成了并⾏执⾏。 所以 ParallelUglifyPlugin 能更快的完成对多个⽂件的压缩⼯作。

使⽤ ParallelUglifyPlugin 也⾮常简单,把原来 Webpack 配置⽂件中内置的 UglifyJsPlugin 去掉后,再替换成 ParallelUglifyPlugin ,相关代码如下:

js
const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');

module.exports = {
  plugins: [
    // 使⽤ ParallelUglifyPlugin 并⾏压缩输出的 JS 代码
    new ParallelUglifyPlugin({
      // 传递给 UglifyJS 的参数
      uglifyJS: {
        output: {
          // 最紧凑的输出
          beautify: false,
          // 删除所有的注释
          comments: false,
        },
        compress: {
          // 在UglifyJs删除没有⽤到的代码时不输出警告
          warnings: false,
          // 删除所有的 `console` 语句,可以兼容ie浏览器
          drop_console: true,
          // 内嵌定义了但是只⽤到⼀次的变量
          collapse_vars: true,
          // 提取出出现多次但是没有定义成变量去引⽤的静态值
          reduce_vars: true,
        },
      },
    }),
  ],
};
const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');

module.exports = {
  plugins: [
    // 使⽤ ParallelUglifyPlugin 并⾏压缩输出的 JS 代码
    new ParallelUglifyPlugin({
      // 传递给 UglifyJS 的参数
      uglifyJS: {
        output: {
          // 最紧凑的输出
          beautify: false,
          // 删除所有的注释
          comments: false,
        },
        compress: {
          // 在UglifyJs删除没有⽤到的代码时不输出警告
          warnings: false,
          // 删除所有的 `console` 语句,可以兼容ie浏览器
          drop_console: true,
          // 内嵌定义了但是只⽤到⼀次的变量
          collapse_vars: true,
          // 提取出出现多次但是没有定义成变量去引⽤的静态值
          reduce_vars: true,
        },
      },
    }),
  ],
};

在通过 new ParallelUglifyPlugin() 实例化时,⽀持以下参数:

  • test:使⽤正则去匹配哪些⽂件需要被 ParallelUglifyPlugin 压缩,默认是 /.js$/,也就是默认压缩所有的 .js ⽂件;
  • include:使⽤正则去命中需要被 ParallelUglifyPlugin 压缩的⽂件。默认为 [];
  • exclude:使⽤正则去命中不需要被 ParallelUglifyPlugin 压缩的⽂件。默认为 [];
  • cacheDir:缓存压缩后的结果,下次遇到⼀样的输⼊时直接从缓存中获取压缩后的结果并返回。
  • cacheDir ⽤于配置缓存存放的⽬录路径。默认不会缓存,想开启缓存请设置⼀个⽬录路径;
  • workerCount:开启⼏个⼦进程去并发的执⾏压缩。默认是当前运⾏电脑的 CPU 核数减去1;
  • sourceMap:是否输出 Source Map,这会导致压缩过程变慢;
  • uglifyJS:⽤于压缩 ES5 代码时的配置,Object 类型,直接透传给 UglifyJS 的参数;
  • uglifyES:⽤于压缩 ES6 代码时的配置,Object 类型,直接透传给 UglifyES 的参数;

其中的 test、include、exclude 与配置 Loader 时的思想和⽤法⼀样。

UglifyES 是 UglifyJS 的变种,专⻔⽤于压缩 ES6 代码,它们两都出⾃于同⼀个项⽬,并且它们两不能同时使⽤。

UglifyES ⼀般⽤于给⽐较新的 JavaScript 运⾏环境压缩代码,例如⽤于 ReactNative 的代码运⾏在兼容性较好的 JavaScriptCore 引擎中,为了得到更好的性能和尺⼨,采⽤ UglifyES 压缩效果会更好;ParallelUglifyPlugin 同时内置了 UglifyJS 和 UglifyES,也就是说 ParallelUglifyPlugin ⽀持并⾏压缩 ES6 代码;

接⼊ ParallelUglifyPlugin 后,项⽬需要安装新的依赖: npm i -D webpack-parallel-uglify-plugin

安装成功后,重新执⾏构建会发现速度变快了许多。如果设置 cacheDir 开启了缓存,在之后的构建中会变的更快

启用 自动刷新

在开发阶段,修改源码是不可避免的操作。 对于开发⽹⻚来说,要想看到修改后的效果,需要刷新浏览器让其重新运⾏最新的代码才⾏。 虽然这相⽐于开发原⽣ iOS 和 Android 应⽤来说要⽅便很多,因为那需要重新编译这个项⽬再运⾏,但我们可以把这个体验优化的更好。 借助⾃动化的⼿段,可以把这些重复的操作交给代码去帮我们完成,在监听到本地源码⽂件发⽣变化时,⾃动重新构建出可运⾏的代码后再控制浏览器刷新。

Webpack 把这些功能都内置了,并且还提供多种⽅案可选。

文件监听

⽂件监听是在发现源码⽂件发⽣变化时,⾃动重新构建出新的输出⽂件。

Webpack 官⽅提供了两⼤模块,⼀个是核⼼的 webpack,⼀个是webpack-dev-server 扩展模块。 ⽽⽂件监听功能是 webpack 模块提供的。

Webpack ⽀持⽂件监听相关的配置项如下:

js
module.export = {
  // 只有在开启监听模式时,watchOptions 才有意义
  // 默认为 false,也就是不开启
  watch: true,
  // 监听模式运⾏时的参数
  // 在开启监听模式时,才有意义
  watchOptions: {
  // 不监听的⽂件或⽂件夹,⽀持正则匹配
  // 默认为空
    ignored: /node_modules/,
    // 监听到变化发⽣后会等300ms再去执⾏动作,防⽌⽂件更新太快导致重新编译频率太⾼
    // 默认为 300ms
    aggregateTimeout: 300,
    // 判断⽂件是否发⽣变化是通过不停的去询问系统指定⽂件有没有变化实现的
    // 默认每隔1000毫秒询问⼀次
    poll: 1000,
  },
};
module.export = {
  // 只有在开启监听模式时,watchOptions 才有意义
  // 默认为 false,也就是不开启
  watch: true,
  // 监听模式运⾏时的参数
  // 在开启监听模式时,才有意义
  watchOptions: {
  // 不监听的⽂件或⽂件夹,⽀持正则匹配
  // 默认为空
    ignored: /node_modules/,
    // 监听到变化发⽣后会等300ms再去执⾏动作,防⽌⽂件更新太快导致重新编译频率太⾼
    // 默认为 300ms
    aggregateTimeout: 300,
    // 判断⽂件是否发⽣变化是通过不停的去询问系统指定⽂件有没有变化实现的
    // 默认每隔1000毫秒询问⼀次
    poll: 1000,
  },
};

要让 Webpack 开启监听模式,有两种⽅式:

  • 在配置⽂件 webpack.config.js 中设置 watch: true;
  • 在执⾏启动 Webpack 命令时,带上 --watch 参数,完整命令是 webpack --watch;

⽂件监听⼯作原理

在 Webpack 中监听⼀个⽂件发⽣变化的原理是定时的去获取这个⽂件的最后编辑时间,每次都存下最新的最后编辑时间,如果发现当前获取的和最后⼀次保存的最后编辑时间不⼀致,就认为该⽂件发⽣了变化。 配置项中的 watchOptions.poll 就是⽤于控制定时检查的周期,具体含义是每隔多少毫秒检查⼀次。

当发现某个⽂件发⽣了变化时,并不会⽴刻告诉监听者,⽽是先缓存起来,收集⼀段时间的变化后,再⼀次性告诉监听者。 配置项中的 watchOptions.aggregateTimeout 就是⽤于配置这个等待时间。 这样做的⽬的是因为我们在编辑代码的过程中可能会⾼频的输⼊⽂字导致⽂件变化的事件⾼频的发⽣,如果每次都重新执⾏构建就会让构建卡死。

对于多个⽂件来说,原理相似,只不过会对列表中的每⼀个⽂件都定时的执⾏检查。 但是这个需要监听的⽂件列表是怎么确定的呢? 默认情况下 Webpack 会从配置的 Entry ⽂件出发,递归解析出 Entry ⽂件所依赖的⽂件,把这些依赖的⽂件都加⼊到监听列表中去。 可⻅ Webpack 这⼀点还是做的很智能的,不是粗暴的直接监听项⽬⽬录下的所有⽂件。

由于保存⽂件的路径和最后编辑时间需要占⽤内存,定时检查周期检查需要占⽤ CPU 以及⽂件 I/O,所以最好减少需要监听的⽂件数量和降低检查频率。

优化⽂件监听性能

在明⽩⽂件监听⼯作原理后,就好分析如何优化⽂件监听性能了。

开启监听模式时,默认情况下会监听配置的 Entry ⽂件和所有其递归依赖的⽂件。 在这些⽂件中会有很多存在于 node_modules 下,因为如今的 Web 项⽬会依赖⼤量的第三⽅模块。 在⼤多数情况下我们都不可能去编辑 node_modules 下的⽂件,⽽是编辑⾃⼰建⽴的源码⽂件。 所以⼀个很⼤的优化点就是忽略掉 node_modules 下的⽂件,不监听它们。相关配置如下:

js
module.export = {
  watchOptions: {
    // 不监听的 node_modules ⽬录下的⽂件
    ignored: /node_modules/,
  }
}
module.export = {
  watchOptions: {
    // 不监听的 node_modules ⽬录下的⽂件
    ignored: /node_modules/,
  }
}

采⽤这种⽅法优化后,Webpack 消耗的内存和 CPU 将会⼤⼤降低。

有时你可能会觉得 node_modules ⽬录下的第三⽅模块有 bug,想修改第三⽅模块的⽂件,然后在⾃⼰的项⽬中试试。 在这种情况下如果使⽤了以上优化⽅法,我们需要重启构建以看到最新效果。 但这种情况毕竟是⾮常少⻅的。

除了忽略掉部分⽂件的优化外,还有如下两种⽅法:

  • watchOptions.aggregateTimeout 值越⼤性能越好,因为这能降低重新构建的频率;
  • watchOptions.poll 值越⼤越好,因为这能降低检查的频率;

但两种优化⽅法的后果是会让你感觉到监听模式的反应和灵敏度降低了。

⾃动刷新浏览器

监听到⽂件更新后的下⼀步是去刷新浏览器,webpack 模块负责监听⽂件, webpack-dev-server模块则负责刷新浏览器。 在使⽤ webpack-dev-server 模块去启动 webpack 模块时,webpack模块的监听模式默认会被开启。 webpack 模块会在⽂件发⽣变化时告诉 webpack-dev-server 模块.

⾃动刷新的原理

控制浏览器刷新有三种⽅法:

  1. 借助浏览器扩展去通过浏览器提供的接⼝刷新;
  2. 往要开发的⽹⻚中注⼊代理客户端代码,通过代理客户端去刷新整个⻚⾯;
  3. 把要开发的⽹⻚装进⼀个 iframe 中,通过刷新 iframe 去看到最新效果;

DevServer ⽀持第2、3种⽅法,第2种是 DevServer 默认采⽤的刷新⽅法。

通过 DevServer 启动构建后,你会看到如下⽇志:

bash
> webpack-dev-server
Project is running at http://localhost:8080/
webpack output is served from /
Hash: e4e2f9508ac286037e71
Version: webpack 3.5.5
Time: 1566ms
  Asset             Size    Chunks              Chunk Names
  bundle.js       1.07 MB    0 [emitted] [big]     main
  bundle.js.map   1.27 MB    0 [emitted]           main
[115] multi (webpack)-dev-server/client?http://localhost:8080 ./main.js 40 bytes {0} [built]
[116] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [built]
[117] ./node_modules/url/url.js 23.3 kB {0} [built]
[120] ./node_modules/querystring-es3/index.js 127 bytes {0} [built]
[123] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[125] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[126] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[158] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
[159] ./node_modules/ansi-html/index.js 4.26 kB {0} [built]
[163] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
[165] (webpack)/hot/emitter.js 77 bytes {0} [built]
[167] ./main.js 2.28 kB {0} [built]
 + 255 hidden modules
> webpack-dev-server
Project is running at http://localhost:8080/
webpack output is served from /
Hash: e4e2f9508ac286037e71
Version: webpack 3.5.5
Time: 1566ms
  Asset             Size    Chunks              Chunk Names
  bundle.js       1.07 MB    0 [emitted] [big]     main
  bundle.js.map   1.27 MB    0 [emitted]           main
[115] multi (webpack)-dev-server/client?http://localhost:8080 ./main.js 40 bytes {0} [built]
[116] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [built]
[117] ./node_modules/url/url.js 23.3 kB {0} [built]
[120] ./node_modules/querystring-es3/index.js 127 bytes {0} [built]
[123] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[125] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[126] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[158] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
[159] ./node_modules/ansi-html/index.js 4.26 kB {0} [built]
[163] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
[165] (webpack)/hot/emitter.js 77 bytes {0} [built]
[167] ./main.js 2.28 kB {0} [built]
 + 255 hidden modules

我们观察到输出的 bundle.js 中包含了以下七个模块:

bash
[116] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [built]
[117] ./node_modules/url/url.js 23.3 kB {0} [built]
[120] ./node_modules/querystring-es3/index.js 127 bytes {0} [built]
[123] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[125] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[126] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[158] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
[116] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [built]
[117] ./node_modules/url/url.js 23.3 kB {0} [built]
[120] ./node_modules/querystring-es3/index.js 127 bytes {0} [built]
[123] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[125] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[126] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[158] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]

这七个模块就是代理客户端的代码,它们被打包进了要开发的⽹⻚代码中。

在浏览器中打开⽹址 http://localhost:8080/ 后, 在浏览器的开发者⼯具中你会发现由代理客户端向 DevServer 发起的 WebSocket 连接:

Alt text

优化⾃动刷新的性能

devServer.inline 配置项,它就是⽤来控制是否往 Chunk 中注⼊代理客户端的,默认会注⼊。 事实上,在开启 inline 时,DevServer 会为每个输出的 Chunk 中注⼊代理客户端的代码,当你的项⽬需要输出的 Chunk 有很多个时,这会导致你的构建缓慢。 其实要完成⾃动刷新,⼀个⻚⾯只需要⼀个代理客户端就⾏了,DevServer 之所以粗暴的为每个 Chunk 都注⼊,是因为它不知道某个⽹⻚依赖哪⼏个 Chunk,索性就全部都注⼊⼀个代理客户端。 ⽹⻚只要依赖了其中任何⼀个 Chunk,代理客户端就被注⼊到⽹⻚中去。

这⾥优化的思路是关闭还不够优雅的 inline 模式,只注⼊⼀个代理客户端。 为了关闭 inline 模式,在启动 DevServer 时,可通过执⾏命令 webpack-dev-server --inline false (也可以在配置⽂件中设置),这时输出的⽇志如下:

bash
> webpack-dev-server --inline false
Project is running at http://localhost:8080/webpack-dev-server/
webpack output is served from /
Hash: 5a43fc44b5e85f4c2cf1
Version: webpack 3.5.5
Time: 1130ms
  Asset        Size       Chunks           Chunk Names
  bundle.js 750 kB        0 [emitted] [big]  main
  bundle.js.map 897 kB    0 [emitted]    main
  [81] ./main.js 2.29 kB  {0}  [built]
  + 169 hidden modules
> webpack-dev-server --inline false
Project is running at http://localhost:8080/webpack-dev-server/
webpack output is served from /
Hash: 5a43fc44b5e85f4c2cf1
Version: webpack 3.5.5
Time: 1130ms
  Asset        Size       Chunks           Chunk Names
  bundle.js 750 kB        0 [emitted] [big]  main
  bundle.js.map 897 kB    0 [emitted]    main
  [81] ./main.js 2.29 kB  {0}  [built]
  + 169 hidden modules

和前⾯的不同在于

  • ⼊⼝⽹址变成了 http://localhost:8080/webpack-dev-server/
  • bundle.js 中不再包含代理客户端的代码了;

在浏览器中打开⽹址 http://localhost:8080/webpack-dev-server/ 后,你会看到如下效果

Alt text

要开发的⽹⻚被放进了⼀个 iframe 中,编辑源码后,iframe 会被⾃动刷新。 同时你会发现构建时间减少了,说明优化⽣效了。 构建性能提升的效果在要输出的 Chunk 数量越多时会显得越突出。

在你关闭了 inline 后,DevServer 会⾃动地提示你通过新⽹址 http://localhost:8080/webpack-dev-server/ 去访问,这点是做的很⼈性化的。

如果你不想通过 iframe 的⽅式去访问,但同时⼜想让⽹⻚保持⾃动刷新功能,你需要⼿动往⽹⻚中注⼊代理客户端脚本,往 index.html 中插⼊以下标签:

js
<!--注⼊ DevServer 提供的代理客户端脚本,这个服务是 DevServer 内置的-->
<script src="http://localhost:8080/webpack-dev-server.js"></script>
<!--注⼊ DevServer 提供的代理客户端脚本,这个服务是 DevServer 内置的-->
<script src="http://localhost:8080/webpack-dev-server.js"></script>

给⽹⻚注⼊以上脚本后,独⽴打开的⽹⻚就能⾃动刷新了。但是要注意在发布到线上时记得删除掉这段⽤于开发环境的代码。

启用 热模块更新(HMR)

要做到实时预览,除了刷新整个⽹⻚外,DevServer 还⽀持⼀种叫做模块热替换( Hot Module Replacement )的技术可在不刷新整个⽹⻚的情况下做到超灵敏的实时预览。 原理是当⼀个源码发⽣变化时,只重新编译发⽣变化的模块,再⽤新输出的模块替换掉浏览器中对应的⽼模块。

模块热替换技术的优势有:

  • 实时预览反应更快,等待时间更短;
  • 不刷新浏览器能保留当前⽹⻚的运⾏状态,例如在使⽤ Redux 来管理数据的应⽤中搭配模块热替换能做到代码更新时 Redux 中的数据还保持不变;

总的来说模块热替换技术很⼤程度上的提⾼了开发效率和体验。

模块热替换的原理

模块热替换的原理和⾃动刷新原理类似,都需要往要开发的⽹⻚中注⼊⼀个代理客户端⽤于连接 DevServer 和⽹⻚, 不同在于模块热替换独特的模块替换机制。

DevServer 默认不会开启模块热替换模式,要开启该模式,只需在启动时带上参数 --hot,完整命令是 webpack-dev-server --hot 。

除了通过在启动时带上 --hot 参数,还可以通过接⼊ Plugin 实现,相关代码如下:

js
const HotModuleReplacementPlugin = require('webpack/lib/HotModuleReplacementPlugin');

module.exports = {
  entry: {
    // 为每个⼊⼝都注⼊代理客户端
    main: ['webpack-dev-server/client?http://localhost:8080/', 'webpack/hot/dev-server', './src/main.js'],
  },
  plugins: [
    // 该插件的作⽤就是实现模块热替换,实际上当启动时带上 `--hot` 参数,会注⼊该插件,⽣成 .hot-update.json ⽂件。
    new HotModuleReplacementPlugin(),
  ],
  devServer: {
    // 告诉 DevServer 要开启模块热替换模式
    hot: true,
  },
};
const HotModuleReplacementPlugin = require('webpack/lib/HotModuleReplacementPlugin');

module.exports = {
  entry: {
    // 为每个⼊⼝都注⼊代理客户端
    main: ['webpack-dev-server/client?http://localhost:8080/', 'webpack/hot/dev-server', './src/main.js'],
  },
  plugins: [
    // 该插件的作⽤就是实现模块热替换,实际上当启动时带上 `--hot` 参数,会注⼊该插件,⽣成 .hot-update.json ⽂件。
    new HotModuleReplacementPlugin(),
  ],
  devServer: {
    // 告诉 DevServer 要开启模块热替换模式
    hot: true,
  },
};

在启动 Webpack 时带上参数 --hot 其实就是⾃动为你完成以上配置。

启动后⽇志如下:

bash
> webpack-dev-server --hot
Project is running at http://localhost:8080/
webpack output is served from /
webpack: wait until bundle finished: /
webpack: wait until bundle finished: /bundle.js
Hash: fe62ac6b753c1d98961b
Version: webpack 3.5.5
Time: 3563ms
 Asset Size Chunks Chunk Names
 bundle.js 1.11 MB 0 [emitted] [big] main
bundle.js.map 1.33 MB 0 [emitted] main
 [50] (webpack)/hot/log.js 1.04 kB {0} [built]
[118] multi (webpack)-dev-server/client?http://localhost:8080 webpack/ho
t/dev-server ./main.js 52 bytes {0} [built]
[119] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [buil
t]
[120] ./node_modules/url/url.js 23.3 kB {0} [built]
[126] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[128] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[129] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[161] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
[166] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
[168] (webpack)/hot/dev-server.js 1.61 kB {0} [built]
[169] (webpack)/hot/log-apply-result.js 1.31 kB {0} [built]
[170] ./main.js 2.35 kB {0} [built]
 + 262 hidden modules
> webpack-dev-server --hot
Project is running at http://localhost:8080/
webpack output is served from /
webpack: wait until bundle finished: /
webpack: wait until bundle finished: /bundle.js
Hash: fe62ac6b753c1d98961b
Version: webpack 3.5.5
Time: 3563ms
 Asset Size Chunks Chunk Names
 bundle.js 1.11 MB 0 [emitted] [big] main
bundle.js.map 1.33 MB 0 [emitted] main
 [50] (webpack)/hot/log.js 1.04 kB {0} [built]
[118] multi (webpack)-dev-server/client?http://localhost:8080 webpack/ho
t/dev-server ./main.js 52 bytes {0} [built]
[119] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [buil
t]
[120] ./node_modules/url/url.js 23.3 kB {0} [built]
[126] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[128] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[129] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[161] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
[166] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
[168] (webpack)/hot/dev-server.js 1.61 kB {0} [built]
[169] (webpack)/hot/log-apply-result.js 1.31 kB {0} [built]
[170] ./main.js 2.35 kB {0} [built]
 + 262 hidden modules

可以看出 bundle.js 代理客户端相关的代码包含九个⽂件:

bash
[119] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [built]
[120] ./node_modules/url/url.js 23.3 kB {0} [built]
[126] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[128] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[129] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[161] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
[166] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
[168] (webpack)/hot/dev-server.js 1.61 kB {0} [built]
[169] (webpack)/hot/log-apply-result.js 1.31 kB {0} [built]
[119] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [built]
[120] ./node_modules/url/url.js 23.3 kB {0} [built]
[126] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[128] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[129] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[161] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
[166] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
[168] (webpack)/hot/dev-server.js 1.61 kB {0} [built]
[169] (webpack)/hot/log-apply-result.js 1.31 kB {0} [built]

相⽐于⾃动刷新的代理客户端,多出了后三个⽤于模块热替换的⽂件,也就是说代理客户端更⼤了。

修改源码 main.css ⽂件后,新输出了如下⽇志:

bash
webpack: Compiling...
Hash: 18f81c959118f6230623
Version: webpack 3.5.5
Time: 551ms
 Asset Size Chunks 
 Chunk Names
 bundle.js 1.11 MB 0 [emitted] [big] main
 0.ea11a51f97f2b52bca7d.hot-update.js 353 bytes 0 [emitted] main
 ea11a51f97f2b52bca7d.hot-update.json 43 bytes [emitted] 
 
 bundle.js.map 1.33 MB 0 [emitted]        main
0.ea11a51f97f2b52bca7d.hot-update.js.map 577 bytes 0 [emitted]  main
 [68] ./node_modules/css-loader!./main.css 217 bytes {0} [built]
[166] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
 + 275 hidden modules
webpack: Compiled successfully.
webpack: Compiling...
Hash: 18f81c959118f6230623
Version: webpack 3.5.5
Time: 551ms
 Asset Size Chunks 
 Chunk Names
 bundle.js 1.11 MB 0 [emitted] [big] main
 0.ea11a51f97f2b52bca7d.hot-update.js 353 bytes 0 [emitted] main
 ea11a51f97f2b52bca7d.hot-update.json 43 bytes [emitted] 
 
 bundle.js.map 1.33 MB 0 [emitted]        main
0.ea11a51f97f2b52bca7d.hot-update.js.map 577 bytes 0 [emitted]  main
 [68] ./node_modules/css-loader!./main.css 217 bytes {0} [built]
[166] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
 + 275 hidden modules
webpack: Compiled successfully.

DevServer 新⽣成了⼀个⽤于替换⽼模块的补丁⽂件 0.ea11a51f97f2b52bca7d.hot-update.js,同时在浏览器开发⼯具中也能看到请求这个补丁的抓包:

Alt text

可⻅补丁中包含了 main.css ⽂件新编译出来 CSS 代码,⽹⻚中的样式也⽴刻变成了源码中描述的那样。

但当你修改 main.js ⽂件时,会发现模块热替换没有⽣效,⽽是整个⻚⾯被刷新了,为什么修改 main.js⽂件时会这样呢?

Webpack 为了让使⽤者在使⽤了模块热替换功能时能灵活地控制⽼模块被替换时的逻辑,可以在源码中定义⼀些代码去做相应的处理。 把的 main.js ⽂件改为如下:

js
import React from 'react';
import { render } from 'react-dom';
import { AppComponent } from './AppComponent';
import './main.css';

render(<AppComponent/>, window.document.getElementById('app'));
// 只有当开启了模块热替换时 module.hot 才存在
if (module.hot) {
  // accept 函数的第⼀个参数指出当前⽂件接受哪些⼦模块的替换,这⾥表示只接受 ./AppComponent 这个⼦模块
  // 第2个参数⽤于在新的⼦模块加载完毕后需要执⾏的逻辑
  module.hot.accept(['./AppComponent'], () => {
    // 新的 AppComponent 加载成功后重新执⾏下组建渲染逻辑
    render(<AppComponent/>, window.document.getElementById('app'));
  });
}
import React from 'react';
import { render } from 'react-dom';
import { AppComponent } from './AppComponent';
import './main.css';

render(<AppComponent/>, window.document.getElementById('app'));
// 只有当开启了模块热替换时 module.hot 才存在
if (module.hot) {
  // accept 函数的第⼀个参数指出当前⽂件接受哪些⼦模块的替换,这⾥表示只接受 ./AppComponent 这个⼦模块
  // 第2个参数⽤于在新的⼦模块加载完毕后需要执⾏的逻辑
  module.hot.accept(['./AppComponent'], () => {
    // 新的 AppComponent 加载成功后重新执⾏下组建渲染逻辑
    render(<AppComponent/>, window.document.getElementById('app'));
  });
}

其中的 module.hot 是当开启模块热替换后注⼊到全局的 API,⽤于控制模块热替换的逻辑。现在修改 AppComponent.js ⽂件,把 Hello,Webpack 改成 Hello,World,你会发现模块热替换⽣效了。 但是当你编辑 main.js 时,你会发现整个⽹⻚被刷新了。为什么修改这两个⽂件会有不⼀样的表现呢?

当⼦模块发⽣更新时,更新事件会⼀层层往上传递,也就是从 AppComponent.js ⽂件传递到 main.js ⽂ 件, 直到有某层的⽂件接受了当前变化的模块,也就是 main.js ⽂件中定义的 module.hot.accept(['./AppComponent'], callback) , 这时就会调⽤ callback 函数去执⾏⾃定义逻辑。如果事件⼀直往上抛到最外层都没有⽂件接受它,就会直接刷新⽹⻚。

那为什么没有地⽅接受过 .css ⽂件,但是修改所有的 .css ⽂件都会触发模块热替换呢? 原因在于style-loader 会注⼊⽤于接受 CSS 的代码。

注意:不要把模块热替换技术⽤于线上环境,它是专⻔为提升开发效率⽣的。

优化模块热替换

在发⽣模块热替换时,你会在浏览器的控制台中看到类似这样的⽇志:

Alt text

其中的 Updated modules: 68 是指 ID 为68的模块被替换了,这对开发者来说很不友好,因为开发者不知道 ID 和模块之间的对应关系,最好是把替换了的模块的名称输出出来。 Webpack 内置的 NamedModulesPlugin 插件可以解决该问题,修改 Webpack 配置⽂件接⼊该插件:

js
const NamedModulesPlugin = require('webpack/lib/NamedModulesPlugin');

module.exports = {
  plugins: [
    // 显示出被替换模块的名称
    new NamedModulesPlugin(),
  ],
};
const NamedModulesPlugin = require('webpack/lib/NamedModulesPlugin');

module.exports = {
  plugins: [
    // 显示出被替换模块的名称
    new NamedModulesPlugin(),
  ],
};

Alt text

除此之外,模块热替换还⾯临着和⾃动刷新⼀样的性能问题,因为它们都需要监听⽂件变化和注⼊客户端。 要优化模块热替换的构建性能,思路⽂件刷新中提到的很类似:监听更少的⽂件,忽略掉 node_modules ⽬录下的⽂件。 但是其中提到的关闭默认的 inline 模式⼿动注⼊代理客户端的优化⽅法不能⽤于在使⽤模块热替换的情况下, 原因在于模块热替换的运⾏依赖在每个 Chunk 中都包含代理客户端的代码。

区分环境

为什么需要区分环境

在开发⽹⻚的时候,⼀般都会有多套运⾏环境,例如:

  1. 在开发过程中⽅便开发调试的环境;
  2. 发布到线上给⽤户使⽤的运⾏环境;

这两套不同的环境虽然都是由同⼀套源代码编译⽽来,但是代码内容却不⼀样,差异包括:

  • 线上代码压缩过;
  • 开发⽤的代码包含⼀些⽤于提示开发者的提示⽇志,这些⽇志普通⽤户不可能去看它;
  • 开发⽤的代码所连接的后端数据接⼝地址也可能和线上环境不同,因为要避免开发过程中造成对线上数据的影响;

为了尽可能的复⽤代码,在构建的过程中需要根据⽬标代码要运⾏的环境⽽输出不同的代码,我们需要⼀套机制在源码中去区分环境。 幸运的是 Webpack 已经为我们实现了这点。

如何区分环境

具体区分⽅法很简单,在源码中通过如下⽅式:

js
if (process.env.NODE_ENV === 'production') {
  console.log('你正在线上环境');
} else {
  console.log('你正在使⽤开发环境');
}
if (process.env.NODE_ENV === 'production') {
  console.log('你正在线上环境');
} else {
  console.log('你正在使⽤开发环境');
}

其⼤概原理是借助于环境变量的值去判断执⾏哪个分⽀。

当你的代码中出现了使⽤ process 模块的语句时,Webpack 就⾃动打包进 process 模块的代码以⽀持⾮ Node.js 的运⾏环境。 当你的代码中没有使⽤ process 时就不会打包进 process 模块的代码。 这个注⼊的 process 模块作⽤是为了模拟 Node.js 中的 process,以⽀持上⾯使⽤的 process.env.NODE_ENV === 'production' 语句。

在构建线上环境代码时,需要给当前运⾏环境设置环境变量 NODE_ENV = 'production',Webpack 相关配置如下:

js
const DefinePlugin = require('webpack/lib/DefinePlugin');

module.exports = {
  plugins: [
    new DefinePlugin({
      // 定义 NODE_ENV 环境变量为 production
      'process.env': {
        NODE_ENV: JSON.stringify('production'),
      },
    }),
  ],
};
const DefinePlugin = require('webpack/lib/DefinePlugin');

module.exports = {
  plugins: [
    new DefinePlugin({
      // 定义 NODE_ENV 环境变量为 production
      'process.env': {
        NODE_ENV: JSON.stringify('production'),
      },
    }),
  ],
};

注意在定义环境变量的值时⽤ JSON.stringify 包裹字符串的原因是环境变量的值需要是⼀个由双引号包裹的字符串,⽽ JSON.stringify('production')的值正好等于'"production"'

js
if (true) {
  console.log('你正在使⽤线上环境');
} else {
  console.log('你正在使⽤开发环境');
}
if (true) {
  console.log('你正在使⽤线上环境');
} else {
  console.log('你正在使⽤开发环境');
}

定义的环境变量的值被代⼊到了源码中, process.env.NODE_ENV === 'production' 被直接替换成了 true。 并且由于此时访问 process 的语句被替换了⽽没有了,Webpack 也不会打包进 process 模块了。

DefinePlugin 定义的环境变量只对 Webpack 需要处理的代码有效,⽽不会影响 Node.js 运⾏时的环境变量的值。

通过 Shell 脚本的⽅式去定义的环境变量,例如 NODE_ENV=production webpack ,Webpack 是不认识的,对 Webpack 需要处理的代码中的环境区分语句是没有作⽤的。

也就是说只需要通过 DefinePlugin 定义环境变量就能使上⾯介绍的环境区分语句正常⼯作,没必要⼜通过 Shell 脚本的⽅式去定义⼀遍。

如果想让 Webpack 使⽤通过 Shell 脚本的⽅式去定义的环境变量,你可以使⽤ EnvironmentPlugin,代码如下:

js
new webpack.EnvironmentPlugin(['NODE_ENV'])
// 等同于
new webpack.DefinePlugin({
  'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
})
new webpack.EnvironmentPlugin(['NODE_ENV'])
// 等同于
new webpack.DefinePlugin({
  'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
})

结合 UglifyJS

其实以上输出的代码还可以进⼀步优化,因为 if(true) 语句永远只会执⾏前⼀个分⽀中的代码,也就是说最佳的输出其实应该直接是:

js
console.log('你正在线上环境');
console.log('你正在线上环境');

Webpack 没有实现去除死代码功能,但是 UglifyJS 可以做这个事情。

第三⽅库中的环境区分

除了在⾃⼰写的源码中可以有环境区分的代码外,很多第三⽅库也做了环境区分的优化。 以 React 为例,它做了两套环境区分,分别是:

  1. 开发环境:包含类型检查、HTML 元素检查等等针对开发者的警告⽇志代码;
  2. 线上环境:去掉了所有针对开发者的代码,只保留让 React 能正常运⾏的部分,以优化⼤⼩和性能;

例如 React 源码中有⼤量类似下⾯这样的代码:

js
if (process.env.NODE_ENV !== 'production') {
  warning(false, '%s(...): Can only update a mounted or mounting component.... ')
}
if (process.env.NODE_ENV !== 'production') {
  warning(false, '%s(...): Can only update a mounted or mounting component.... ')
}

如果你不定义 NODE_ENV=production 那么这些警告⽇志就会被包含到输出的代码中,输出的⽂件将会⾮常⼤。

process.env.NODE_ENV !== 'production' 中的 NODE_ENV'production' 两个值是社区的约定,通常使⽤这条判断语句在区分开发环境和线上环境。

压缩代码

压缩代码 浏览器从服务器访问⽹⻚时获取的 JavaScript、CSS 资源都是⽂本形式的,⽂件越⼤⽹⻚加载时间越⻓。 为了提升⽹⻚加速速度和减少⽹络传输流量,可以对这些资源进⾏压缩。 压缩的⽅法除了可以通过 GZIP 算法对⽂件压缩外,还可以对⽂本本身进⾏压缩。 对⽂本本身进⾏压缩的作⽤除了有提升⽹⻚加载速度的优势外,还具有混淆源码的作⽤。 由于压缩后的代码可读性⾮常差,就算别⼈下载到了⽹⻚的代码,也⼤⼤增加了代码分析和改造的难度。

压缩 JavaScript

⽬前最成熟的 JavaScript 代码压缩⼯具是 UglifyJS , 它会分析 JavaScript 代码语法树,理解代码含义,从⽽能做到诸如去掉⽆效代码、去掉⽇志输出代码、缩短变量名等优化。

要在 Webpack 中接⼊ UglifyJS 需要通过插件的形式,⽬前有两个成熟的插件,分别是:

  • UglifyJsPlugin :通过封装 UglifyJS 实现压缩;
  • ParallelUglifyPlugin :多进程并⾏处理压缩;

UglifyJS 提供了⾮常多的选择⽤于配置在压缩过程中采⽤哪些规则,所有的选项说明可以在 其官⽅⽂档上看到。 由于选项⾮常多,就挑出⼀些常⽤的说明:

  • sourceMap :是否为压缩后的代码⽣成对应的 Source Map,默认为不⽣成,开启后耗时会⼤⼤增加。⼀般不会把压缩后的代码的 Source Map 发送给⽹站⽤户的浏览器,⽽是⽤于内部开发⼈员调试线上代码时使⽤;
  • beautify : 是否输出可读性较强的代码,即会保留空格和制表符,默认为是,为了达到更好的压缩效果,可以设置为false;
  • comments :是否保留代码中的注释,默认为保留,为了达到更好的压缩效果,可以设置为 false;
  • compress.warnings :是否在 UglifyJs 删除没有⽤到的代码时输出警告信息,默认为输出,可以设置为 false 以关闭这些作⽤不⼤的警告;
  • drop_console :是否剔除代码中所有的 console 语句,默认为不剔除。开启后不仅可以提升代码压缩效果,也可以兼容不⽀持 console 语句 IE 浏览器;
  • collapse_vars :是否内嵌定义了但是只⽤到⼀次的变量,例如把 var x = 5; y = x 转换成 y = 5,默认为不转换。为了达到更好的压缩效果,可以设置为 true;
  • reduce_vars : 是否提取出出现多次但是没有定义成变量去引⽤的静态值,例如把 x = 'Hello';y = 'Hello' 转换成 var a = 'Hello'; x = a; y = b,默认为不转换。为了达到更好的压缩效果,可以设置为 true;

也就是说,在不影响代码正确执⾏的前提下,最优化的代码压缩配置为如下:

js
const UglifyJSPlugin = require('webpack/lib/optimize/UglifyJsPlugin');

module.exports = {
  plugins: [
    // 压缩输出的 JS 代码
    new UglifyJSPlugin({
      compress: {
        // 在UglifyJs删除没有⽤到的代码时不输出警告
        warnings: false,
        // 删除所有的 `console` 语句,可以兼容ie浏览器
        drop_console: true,
        // 内嵌定义了但是只⽤到⼀次的变量
        collapse_vars: true,
        // 提取出出现多次但是没有定义成变量去引⽤的静态值
        reduce_vars: true,
      },
      output: {
        // 最紧凑的输出
        beautify: false,
        // 删除所有的注释
        comments: false,
      },
    }),
  ],
};
const UglifyJSPlugin = require('webpack/lib/optimize/UglifyJsPlugin');

module.exports = {
  plugins: [
    // 压缩输出的 JS 代码
    new UglifyJSPlugin({
      compress: {
        // 在UglifyJs删除没有⽤到的代码时不输出警告
        warnings: false,
        // 删除所有的 `console` 语句,可以兼容ie浏览器
        drop_console: true,
        // 内嵌定义了但是只⽤到⼀次的变量
        collapse_vars: true,
        // 提取出出现多次但是没有定义成变量去引⽤的静态值
        reduce_vars: true,
      },
      output: {
        // 最紧凑的输出
        beautify: false,
        // 删除所有的注释
        comments: false,
      },
    }),
  ],
};

从以上配置中可以看出 Webpack 内置了 UglifyJsPlugin,需要指出的是 UglifyJsPlugin 当前采⽤的是UglifyJS2 ⽽不是⽼的 UglifyJS1, 这两个版本的 UglifyJS 在配置上有所区别,看⽂档时注意版本。 除此之外 Webpack 还提供了⼀个更简便的⽅法来接⼊ UglifyJSPlugin,直接在启动 Webpack 时带上 --optimize-minimize 参数,即 webpack --optimize-minimize , 这样 Webpack 会⾃动为你注⼊⼀个带有默认配置的UglifyJSPlugin 。

压缩ES6

虽然当前⼤多数 JavaScript 引擎还不完全⽀持 ES6 中的新特性,但在⼀些特定的运⾏环境下已经可以直接执⾏ ES6 代码了,例如最新版的 Chrome、ReactNative 的引擎 JavaScriptCore。

运⾏ ES6 的代码相⽐于转换后的 ES5 代码有如下优点:

  • ⼀样的逻辑⽤ ES6 实现的代码量⽐ ES5 更少;
  • JavaScript 引擎对 ES6 中的语法做了性能优化,例如针对 const 申明的变量有更快的读取速度;

所以在运⾏环境允许的情况下,我们要尽可能的使⽤原⽣的 ES6 代码去运⾏,⽽不是转换后的 ES5 代码。

在你⽤上⾯所讲的压缩⽅法去压缩 ES6 代码时,你会发现 UglifyJS 会报错退出,原因是 UglifyJS 只认识 ES5 语法的代码。 为了压缩 ES6 代码,需要使⽤专⻔针对 ES6 代码的 UglifyES。

UglifyES 和 UglifyJS 来⾃同⼀个项⽬的不同分⽀,它们的配置项基本相同,只是接⼊ Webpack 时有所区别。 在给 Webpack 接⼊ UglifyES 时,不能使⽤内置的 UglifyJsPlugin,⽽是需要单独安装和使⽤最新版本的 uglifyjs-webpack-plugin。

js
const UglifyESPlugin = require('uglifyjs-webpack-plugin');

module.exports = {
  plugins: [
    new UglifyESPlugin({
      // 多嵌套了⼀层
      uglifyOptions: {
        compress: {
          // 在UglifyJs删除没有⽤到的代码时不输出警告
          warnings: false,
          // 删除所有的 `console` 语句,可以兼容ie浏览器
          drop_console: true,
          // 内嵌定义了但是只⽤到⼀次的变量
          collapse_vars: true,
          // 提取出出现多次但是没有定义成变量去引⽤的静态值
          reduce_vars: true,
        },
        output: {
          // 最紧凑的输出
          beautify: false,
          // 删除所有的注释
          comments: false,
        },
      },
    }),
  ],
};
const UglifyESPlugin = require('uglifyjs-webpack-plugin');

module.exports = {
  plugins: [
    new UglifyESPlugin({
      // 多嵌套了⼀层
      uglifyOptions: {
        compress: {
          // 在UglifyJs删除没有⽤到的代码时不输出警告
          warnings: false,
          // 删除所有的 `console` 语句,可以兼容ie浏览器
          drop_console: true,
          // 内嵌定义了但是只⽤到⼀次的变量
          collapse_vars: true,
          // 提取出出现多次但是没有定义成变量去引⽤的静态值
          reduce_vars: true,
        },
        output: {
          // 最紧凑的输出
          beautify: false,
          // 删除所有的注释
          comments: false,
        },
      },
    }),
  ],
};

同时,为了不让 babel-loader 输出 ES5 语法的代码,需要去掉 .babelrc 配置⽂件中的 babel-preset-env ,但是其它的 Babel 插件,⽐如 babel-preset-react 还是要保留, 因为正是 babel-preset-env 负责把 ES6 代码转换为 ES5 代码。

压缩 CSS

CSS 代码也可以像 JavaScript 那样被压缩,以达到提升加载速度和代码混淆的作⽤。 ⽬前⽐较成熟可靠的 CSS 压缩⼯具是 cssnano,基于 PostCSS。 cssnano 能理解 CSS 代码的含义,⽽不仅仅是删掉空格,例如:

  • margin: 10px 20px 10px 20px 被压缩成 margin: 10px 20px ;
  • color: #ff0000 被压缩成 color:red ;

还有很多压缩规则可以去其官⽹查看,通常压缩率能达到 60%。

把 cssnano 接⼊到 Webpack 中也⾮常简单,因为 css-loader 已经将其内置了,要开启 cssnano 去压缩代码只需要开启 css-loader 的 minimize 选项。 相关 Webpack 配置如下:

js
const { WebPlugin } = require('web-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/, // 增加对 CSS ⽂件的⽀持
        // 提取出 Chunk 中的 CSS 代码到单独的⽂件中
        use: ExtractTextPlugin.extract({
          // 通过 minimize 选项压缩 CSS 代码
          use: ['css-loader?minimize'],
        }),
      },
    ],
  },
  plugins: [
    // ⽤ WebPlugin ⽣成对应的 HTML ⽂件
    new WebPlugin({
      template: './template.html', // HTML 模版⽂件所在的⽂件路径
      filename: 'index.html', // 输出的 HTML 的⽂件名称
    }),
    new ExtractTextPlugin({
      filename: '[name]_[contenthash:8].css', // 给输出的 CSS ⽂件名称加上 Hash 值
    }),
  ],
};
const { WebPlugin } = require('web-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/, // 增加对 CSS ⽂件的⽀持
        // 提取出 Chunk 中的 CSS 代码到单独的⽂件中
        use: ExtractTextPlugin.extract({
          // 通过 minimize 选项压缩 CSS 代码
          use: ['css-loader?minimize'],
        }),
      },
    ],
  },
  plugins: [
    // ⽤ WebPlugin ⽣成对应的 HTML ⽂件
    new WebPlugin({
      template: './template.html', // HTML 模版⽂件所在的⽂件路径
      filename: 'index.html', // 输出的 HTML 的⽂件名称
    }),
    new ExtractTextPlugin({
      filename: '[name]_[contenthash:8].css', // 给输出的 CSS ⽂件名称加上 Hash 值
    }),
  ],
};

CDN 加速

虽然前⾯通过了压缩代码的⼿段来减⼩⽹络传输⼤⼩,但实际上最影响⽤户体验的还是⽹⻚⾸次打开时的加载等待。 导致这个问题的根本是⽹络传输过程耗时⼤,CDN 的作⽤就是加速⽹络传输。

什么是 CDN

CDN ⼜叫内容分发⽹络,通过把资源部署到世界各地,⽤户在访问时按照就近原则从离⽤户最近的服务器获取资源,从⽽加速资源的获取速度。 CDN 其实是通过优化物理链路层传输过程中的⽹速有限、丢包等问题来提升⽹速的,其⼤致原理可以如下:

Alt text

可以简单的把 CDN 服务看作成速度更快的 HTTP 服务。 并且⽬前很多⼤公司都会建⽴⾃⼰的 CDN 服务,就算你⾃⼰没有资源去搭建⼀套 CDN服务,各⼤云服务提供商都提供了按量收费的 CDN 服务。

接⼊ CDN

要给⽹站接⼊ CDN,需要把⽹⻚的静态资源上传到 CDN 服务上去,在服务这些静态资源的时候需要通过 CDN 服务提供的 URL 地址去访问。 举个详细的例⼦,有⼀个单⻚应⽤,构建出的代码结构如下:

bash
dist
|-- app_9d89c964.js
|-- app_a6976b6d.css
|-- arch_ae805d49.png
`-- index.html
dist
|-- app_9d89c964.js
|-- app_a6976b6d.css
|-- arch_ae805d49.png
`-- index.html

其中 index.html 内容如下:

html
<html>
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="app_a6976b6d.css">
</head>
<body>
  <div id="app"></div>
  <script src="app_9d89c964.js"></script>
</body>
</html>
<html>
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="app_a6976b6d.css">
</head>
<body>
  <div id="app"></div>
  <script src="app_9d89c964.js"></script>
</body>
</html>

app_a6976b6d.css 内容如下:

css
body{background:url(arch_ae805d49.png) repeat}
h1{color:red}
body{background:url(arch_ae805d49.png) repeat}
h1{color:red}

可以看出到导⼊资源时都是通过相对路径去访问的,当把这些资源都放到同⼀个 CDN 服务上去时,⽹⻚是能正常使⽤的。 但需要注意的是由于 CDN 服务⼀般都会给资源开启很⻓时间的缓存,例如⽤户从 CDN 上获取到了 index.html 这个⽂件后, 即使之后的发布操作把 index.html ⽂件给重新覆盖了,但是⽤户在很⻓⼀段时间内还是运⾏的之前的版本,这会新的导致发布不能⽴即⽣效。

要避免以上问题,业界⽐较成熟的做法是这样的:

  • 针对 HTML ⽂件:不开启缓存,把 HTML 放到⾃⼰的服务器上,⽽不是 CDN 服务上,同时关闭⾃⼰服务器上的缓存。⾃⼰的服务器只提供 HTML ⽂件和数据接⼝;
  • 针对静态的 JavaScript、CSS、图⽚等⽂件:开启 CDN 和缓存,上传到 CDN 服务上去,同时给每个⽂件名带上由⽂件内容算出的 Hash 值, 例如上⾯的 app_a6976b6d.css ⽂件。 带上Hash 值的原因是⽂件名会随着⽂件内容⽽变化,只要⽂件发⽣变化其对应的 URL 就会变化,它就会被重新下载,⽆论缓存时间有多⻓;

采⽤以上⽅案后,在 HTML ⽂件中的资源引⼊地址也需要换成 CDN 服务提供的地址,例如以上的 index.html 变为如下:

html
<html>
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="//cdn.com/id/app_a6976b6d.css">
</head>
<body>
<div id="app"></div>
  <script src="//cdn.com/id/app_9d89c964.js"></script>
</body>
</html>
<html>
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="//cdn.com/id/app_a6976b6d.css">
</head>
<body>
<div id="app"></div>
  <script src="//cdn.com/id/app_9d89c964.js"></script>
</body>
</html>

并且 app_a6976b6d.css 的内容也应该变为如下:

css
body{background:url(//cdn.com/id/arch_ae805d49.png) repeat}
h1{color:red}
body{background:url(//cdn.com/id/arch_ae805d49.png) repeat}
h1{color:red}

也就是说,之前的相对路径,都变成了绝对的指向 CDN 服务的 URL 地址。

除此之外,如果你还知道浏览器有⼀个规则是同⼀时刻针对同⼀个域名的资源并⾏请求是有限制的话(具体数字⼤概4个左右,不同浏览器可能不同), 你会发现上⾯的做法有个很⼤的问题。 由于所有静态资源都放到了同⼀个 CDN 服务的域名下,也就是上⾯的 cdn.com 。 如果⽹⻚的资源很多,例如有很多图⽚,就会导致资源的加载被阻塞,因为同时只能加载⼏个,必须等其它资源加载完才能继续加载。 要解决这个问题,可以把这些静态资源分散到不同的 CDN 服务上去, 例如把 JavaScript ⽂件放到 js.cdn.com 域名下、把 CSS ⽂件放到 css.cdn.com 域名下、图⽚⽂件放到 img.cdn.com 域名下, 这样做之后 index.html 需要变成这样:

html
<html>
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="//css.cdn.com/id/app_a6976b6d.css">
</head>
<body>
  <div id="app"></div>
  <script src="//js.cdn.com/id/app_9d89c964.js"></script>
</body>
</html>
<html>
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="//css.cdn.com/id/app_a6976b6d.css">
</head>
<body>
  <div id="app"></div>
  <script src="//js.cdn.com/id/app_9d89c964.js"></script>
</body>
</html>

使⽤了多个域名后⼜会带来⼀个新问题:增加域名解析时间。是否采⽤多域名分散资源需要根据⾃⼰的需求去衡量得失。 当然你可以通过在 HTML HEAD 标签中 加⼊ <link rel="dns-prefetch" href="//js.cdn.com"> 去预解析域名,以降低域名解析带来的延迟。

⽤ Webpack 实现 CDN 的接⼊

总结上⾯所说的,构建需要实现以下⼏点:

  • 静态资源的导⼊ URL 需要变成指向 CDN 服务的绝对路径的 URL ⽽不是相对于 HTML ⽂件的 URL;
  • 静态资源的⽂件名称需要带上有⽂件内容算出来的 Hash 值,以防⽌被缓存;
  • 不同类型的资源放到不同域名的 CDN 服务上去,以防⽌资源的并⾏加载被阻塞;

先来看下要实现以上要求的最终 Webpack 配置:

js
const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const { WebPlugin } = require('web-webpack-plugin');

module.exports = {
  // 省略 entry 配置...
  output: {
    // 给输出的 JavaScript ⽂件名称加上 Hash 值
    filename: '[name]_[chunkhash:8].js',
    path: path.resolve(__dirname, './dist'),
    // 指定存放 JavaScript ⽂件的 CDN ⽬录 URL
    publicPath: '//js.cdn.com/id/',
  },
  module: {
    rules: [
      {
        // 增加对 CSS ⽂件的⽀持
        test: /\.css$/,
        // 提取出 Chunk 中的 CSS 代码到单独的⽂件中
        use: ExtractTextPlugin.extract({
          // 压缩 CSS 代码
          use: ['css-loader?minimize'],
          // 指定存放 CSS 中导⼊的资源(例如图⽚)的 CDN ⽬录 URL
          publicPath: '//img.cdn.com/id/',
        }),
      },
      {
        // 增加对 PNG ⽂件的⽀持
        test: /\.png$/,
        // 给输出的 PNG ⽂件名称加上 Hash 值
        use: ['file-loader?name=[name]_[hash:8].[ext]'],
      },
      // 省略其它 Loader 配置...
    ],
  },
  plugins: [
    // 使⽤ WebPlugin ⾃动⽣成 HTML
    new WebPlugin({
      // HTML 模版⽂件所在的⽂件路径
      template: './template.html',
      // 输出的 HTML 的⽂件名称
      filename: 'index.html',
      // 指定存放 CSS ⽂件的 CDN ⽬录 URL
      stylePublicPath: '//css.cdn.com/id/',
    }),
    new ExtractTextPlugin({
      // 给输出的 CSS ⽂件名称加上 Hash 值
      filename: '[name]_[contenthash:8].css',
    }),
  // 省略代码压缩插件配置...
  ],
};
const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const { WebPlugin } = require('web-webpack-plugin');

module.exports = {
  // 省略 entry 配置...
  output: {
    // 给输出的 JavaScript ⽂件名称加上 Hash 值
    filename: '[name]_[chunkhash:8].js',
    path: path.resolve(__dirname, './dist'),
    // 指定存放 JavaScript ⽂件的 CDN ⽬录 URL
    publicPath: '//js.cdn.com/id/',
  },
  module: {
    rules: [
      {
        // 增加对 CSS ⽂件的⽀持
        test: /\.css$/,
        // 提取出 Chunk 中的 CSS 代码到单独的⽂件中
        use: ExtractTextPlugin.extract({
          // 压缩 CSS 代码
          use: ['css-loader?minimize'],
          // 指定存放 CSS 中导⼊的资源(例如图⽚)的 CDN ⽬录 URL
          publicPath: '//img.cdn.com/id/',
        }),
      },
      {
        // 增加对 PNG ⽂件的⽀持
        test: /\.png$/,
        // 给输出的 PNG ⽂件名称加上 Hash 值
        use: ['file-loader?name=[name]_[hash:8].[ext]'],
      },
      // 省略其它 Loader 配置...
    ],
  },
  plugins: [
    // 使⽤ WebPlugin ⾃动⽣成 HTML
    new WebPlugin({
      // HTML 模版⽂件所在的⽂件路径
      template: './template.html',
      // 输出的 HTML 的⽂件名称
      filename: 'index.html',
      // 指定存放 CSS ⽂件的 CDN ⽬录 URL
      stylePublicPath: '//css.cdn.com/id/',
    }),
    new ExtractTextPlugin({
      // 给输出的 CSS ⽂件名称加上 Hash 值
      filename: '[name]_[contenthash:8].css',
    }),
  // 省略代码压缩插件配置...
  ],
};

tree-shaking

认识 Tree Shaking

Tree Shaking 可以⽤来剔除 JavaScript 中⽤不上的死代码。 它依赖静态的 ES6 模块化语法,例如通过 import 和 export 导⼊导出。 Tree Shaking 最先在 Rollup 中出现,Webpack 在 2.0 版本中将其引⼊。

为了更直观的理解它,来看⼀个具体的例⼦。 假如有⼀个⽂件 util.js ⾥存放了很多⼯具函数和常量,在 main.js 中会导⼊和使⽤ util.js ,代码如下:

util.js 源码:

js
export function funcA() {}
export function funB() {}
export const a = 'a';
export function funcA() {}
export function funB() {}
export const a = 'a';

main.js 源码:

js
import {funcA} from './util.js';
funcA();
import {funcA} from './util.js';
funcA();

Tree Shaking 后的 util.js :

js
export function funcA() {}
export function funcA() {}

由于只⽤到了 util.js 中的 funcA,所以剩下的都被 Tree Shaking 当作死代码给剔除了。

需要注意的是要让 Tree Shaking 正常⼯作的前提是交给 Webpack 的 JavaScript 代码必须是采⽤ES6 模块化语法的, 因为 ES6 模块化语法是静态的(导⼊导出语句中的路径必须是静态的字符串,⽽且不能放⼊其它代码块中),这让 Webpack 可以简单的分析出哪些 export 的被 import 过了。 如果采⽤ ES5 中的模块化,例如 module.export={...} 、 require(x+y) 、 if(x) { require('./util') } ,Webpack ⽆法分析出哪些代码可以剔除。

⽬前的 Tree Shaking 还有些的局限性,经实验发现:

  1. 不会对entry⼊⼝⽂件做 Tree Shaking;
  2. 不会对按序加载出去的代码做 Tree Shaking;

接⼊ Tree Shaking

上⾯讲了 Tree Shaking 是做什么的,接下来如何配置 Webpack 让 Tree Shaking ⽣效。

⾸先,为了把采⽤ ES6 模块化的代码交给 Webpack,需要配置 Babel 让其保留 ES6 模块化语句,修改 .babelrc ⽂件为如下:

json
{
  "presets": [
    [
      "env",
      {
        "modules": false
      }
    ]
  ]
}
{
  "presets": [
    [
      "env",
      {
        "modules": false
      }
    ]
  ]
}

其中 "modules": false 的含义是关闭 Babel 的模块转换功能,保留原本的 ES6 模块化语法。

配置好 Babel 后,重新运⾏ Webpack,在启动 Webpack 时带上 --display-used-exports 参数,以⽅便追踪 Tree Shaking 的⼯作, 这时你会发现在控制台中输出了如下的⽇志:

bash
> webpack --display-used-exports
bundle.js 3.5 kB 0 [emitted] main
 [0] ./main.js 41 bytes {0} [built]
 [1] ./util.js 511 bytes {0} [built]
 [only some exports used: funcA]
> webpack --display-used-exports
bundle.js 3.5 kB 0 [emitted] main
 [0] ./main.js 41 bytes {0} [built]
 [1] ./util.js 511 bytes {0} [built]
 [only some exports used: funcA]

其中 [only some exports used: funcA] 提示了 util.js 只导出了⽤到的 funcA,说明Webpack 确实正确的分析出了如何剔除死代码。

但当你打开 Webpack 输出的 bundle.js ⽂件看下时,你会发现⽤不上的代码还在⾥⾯,如下:

js
/* harmony export (immutable) */
__webpack_exports__["a"] = funcA;
/* unused harmony export funB */
function funcA() {
  console.log('funcA');
}
function funB() {
  console.log('funcB');
}
/* harmony export (immutable) */
__webpack_exports__["a"] = funcA;
/* unused harmony export funB */
function funcA() {
  console.log('funcA');
}
function funB() {
  console.log('funcB');
}

Webpack 只是指出了哪些函数⽤上了哪些没⽤上,要剔除⽤不上的代码还得经过 UglifyJS 去处理⼀遍。 要接⼊ UglifyJS 也很简单,不仅可以UglifyJSPlugin去实现, 也可以简单的通过在启动Webpack 时带上 --optimize-minimize 参数,为了快速验证 Tree Shaking 我们采⽤较简单的后者来实验下。

通过 webpack --display-used-exports --optimize-minimize 重启 Webpack 后,打开新输出的 bundle.js,内容如下:

js
function r() {
 console.log("funcA")
}
t.a = r
function r() {
 console.log("funcA")
}
t.a = r

可以看出 Tree Shaking 确实做到了,⽤不上的代码都被剔除了。

当你的项⽬使⽤了⼤量第三⽅库时,你会发现 Tree Shaking 似乎不⽣效了,原因是⼤部分 Npm 中的代码都是采⽤的 CommonJS 语法, 这导致 Tree Shaking ⽆法正常⼯作⽽降级处理。 但幸运的时有些库考虑到了这点,这些库在发布到 Npm 上时会同时提供两份代码,⼀份采⽤ CommonJS 模块化语法,⼀份采⽤ ES6 模块化语法。 并且在 package.json ⽂件中分别指出这两份代码的⼊⼝。

以 redux 库为例,其发布到 Npm 上的⽬录结构为:

bash
node_modules/redux
|-- es
| |-- index.js # 采⽤ ES6 模块化语法
|-- lib
| |-- index.js # 采⽤ ES5 模块化语法
|-- package.json
node_modules/redux
|-- es
| |-- index.js # 采⽤ ES6 模块化语法
|-- lib
| |-- index.js # 采⽤ ES5 模块化语法
|-- package.json

package.json ⽂件中有两个字段:

json
{
  "main": "lib/index.js", // 指明采⽤ CommonJS 模块化的代码⼊⼝
  "jsnext:main": "es/index.js" // 指明采⽤ ES6 模块化的代码⼊⼝
}
{
  "main": "lib/index.js", // 指明采⽤ CommonJS 模块化的代码⼊⼝
  "jsnext:main": "es/index.js" // 指明采⽤ ES6 模块化的代码⼊⼝
}

mainFields ⽤于配置采⽤哪个字段作为模块的⼊⼝描述。 为了让 Tree Shaking 对 redux ⽣效,需要配置 Webpack 的⽂件寻找规则为如下:

js
module.exports = {
  resolve: {
  // 针对 Npm 中的第三⽅模块优先采⽤ jsnext:main 中指向的 ES6 模块化语法的⽂件
    mainFields: ['jsnext:main', 'browser', 'main'],
  },
};
module.exports = {
  resolve: {
  // 针对 Npm 中的第三⽅模块优先采⽤ jsnext:main 中指向的 ES6 模块化语法的⽂件
    mainFields: ['jsnext:main', 'browser', 'main'],
  },
};

以上配置的含义是优先使⽤ jsnext:main 作为⼊⼝,如果不存在 jsnext:main 就采⽤ browser或者 main 作为⼊⼝。 虽然并不是每个 Npm 中的第三⽅模块都会提供 ES6 模块化语法的代码,但对于提供了的不能放过,能优化的就优化。

⽬前越来越多的 Npm 中的第三⽅模块考虑到了 Tree Shaking,并对其提供了⽀持。 采⽤ jsnext:main 作为 ES6 模块化代码的⼊⼝是社区的⼀个约定,假如将来你要发布⼀个库到 Npm 时,希望你能⽀持 Tree Shaking, 以让 Tree Shaking 发挥更⼤的优化效果。

提取公共代码

为什么需要提取公共代码

⼤型⽹站通常会由多个⻚⾯组成,每个⻚⾯都是⼀个独⽴的单⻚应⽤。 但由于所有⻚⾯都采⽤同样的技术栈,以及使⽤同⼀套样式代码,这导致这些⻚⾯之间有很多相同的代码。 如果每个⻚⾯的代码都把这些公共的部分包含进去,会造成以下问题:

  • 相同的资源被重复的加载,浪费⽤户的流量和服务器的成本;
  • 每个⻚⾯需要加载的资源太⼤,导致⽹⻚⾸屏加载缓慢,影响⽤户体验;

如果把多个⻚⾯公共的代码抽离成单独的⽂件,就能优化以上问题。 原因是假如⽤户访问了⽹站的其中⼀个⽹⻚,那么访问这个⽹站下的其它⽹⻚的概率将⾮常⼤。 在⽤户第⼀次访问后,这些⻚⾯公共代码的⽂件已经被浏览器缓存起来,在⽤户切换到其它⻚⾯时,存放公共代码的⽂件就不会再重新加载,⽽是直接从缓存中获取。 这样做后有如下好处:

  • 减少⽹络传输流量,降低服务器成本;
  • 虽然⽤户第⼀次打开⽹站的速度得不到优化,但之后访问其它⻚⾯的速度将⼤⼤提升;

如何提取公共代码

知道了提取公共代码会有什么好处,但是在实战中具体要怎么做,以达到效果最优呢? 通常可以采⽤以下原则去为你的⽹站提取公共代码:

  • 根据你⽹站所使⽤的技术栈,找出⽹站所有⻚⾯都需要⽤到的基础库,以采⽤ React 技术栈的⽹站为例,所有⻚⾯都会依赖 react、react-dom 等库,把它们提取到⼀个单独的⽂件。 ⼀般把这个⽂件叫做 base.js ,因为它包含所有⽹⻚的基础运⾏环境;
  • 在剔除了各个⻚⾯中被 base.js 包含的部分代码外,再找出所有⻚⾯都依赖的公共部分的代码提取出来放到 common.js 中去;
  • 再为每个⽹⻚都⽣成⼀个单独的⽂件,这个⽂件中不再包含 base.js 和 common.js 中包含的部分,⽽只包含各个⻚⾯单独需要的部分代码;

⽂件之间的结构图如下:

Alt text

既然能找出所有⻚⾯都依赖的公共代码,并提取出来放到 common.js 中去,为什么还需要再把⽹站所有⻚⾯都需要⽤到的基础库提取到 base.js 去呢? 原因是为了⻓期的缓存 base.js 这个⽂件。

发布到线上的⽂件都会采⽤CDN的⽅法,对静态⽂件的⽂件名都附加根据⽂件内容计算出 Hash 值,也就是最终 base.js 的⽂件名会变成 base_3b1682ac.js ,以⻓期缓存⽂件。 ⽹站通常会不断的更新发布,每次发布都会导致 common.js 和各个⽹⻚的 JavaScript ⽂件都会因为⽂件内容发⽣变化⽽导致其 Hash 值被更新,也就是缓存被更新。

把所有⻚⾯都需要⽤到的基础库提取到 base.js 的好处在于只要不升级基础库的版本,base.js 的⽂件内容就不会变化,Hash 值不会被更新,缓存就不会被更新。 每次发布浏览器都会使⽤被缓存的 base.js ⽂件,⽽不⽤去重新下载 base.js ⽂件。 由于 base.js 通常会很⼤,这对提升⽹⻚加速速度能起到很⼤的效果。

如何通过 Webpack 提取公共代码

Webpack 内置了专⻔⽤于提取多个 Chunk 中公共部分的插件 CommonsChunkPlugin , CommonsChunkPlugin ⼤致使⽤⽅法如下:

js
const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');

new CommonsChunkPlugin({
  // 从哪些 Chunk 中提取
  chunks: ['a', 'b'],
  // 提取出的公共部分形成⼀个新的 Chunk,这个新 Chunk 的名称
  name: 'common',
});
const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');

new CommonsChunkPlugin({
  // 从哪些 Chunk 中提取
  chunks: ['a', 'b'],
  // 提取出的公共部分形成⼀个新的 Chunk,这个新 Chunk 的名称
  name: 'common',
});

以上配置就能从⽹⻚ A 和⽹⻚ B 中抽离出公共部分,放到 common 中。

每个 CommonsChunkPlugin 实例都会⽣成⼀个新的 Chunk,这个新 Chunk 中包含了被提取出的代码,在使⽤过程中必须指定 name 属性,以告诉插件新⽣成的 Chunk 的名称。 其中 chunks 属性指明从哪些已有的 Chunk 中提取,如果不填该属性,则默认会从所有已知的 Chunk 中提取。

Chunk 是⼀系列⽂件的集合,⼀个 Chunk 中会包含这个 Chunk 的⼊⼝⽂件和⼊⼝⽂件依赖的⽂件。 通过以上配置输出的 common Chunk 中会包含所有⻚⾯都依赖的基础运⾏库 react、react-dom,为了把基础运⾏库从 common 中抽离到 base 中去,还需要做⼀些处理。

⾸先需要先配置⼀个 Chunk,这个 Chunk 中只依赖所有⻚⾯都依赖的基础库以及所有⻚⾯都使⽤的样 式,为此需要在项⽬中写⼀个⽂件 base.js 来描述 base Chunk 所依赖的模块,⽂件内容如下:

js
// 所有⻚⾯都依赖的基础库
import 'react';
import 'react-dom';
// 所有⻚⾯都使⽤的样式
import './base.css';
// 所有⻚⾯都依赖的基础库
import 'react';
import 'react-dom';
// 所有⻚⾯都使⽤的样式
import './base.css';

接着再修改 Webpack 配置,在 entry 中加⼊ base,相关修改如下:

js
module.exports = {
  entry: {
    base: './base.js',
  },
};
module.exports = {
  entry: {
    base: './base.js',
  },
};

以上就完成了对新 Chunk base 的配置。 为了从 common 中提取出 base 也包含的部分,还需要配置⼀个 CommonsChunkPlugin ,相关代码如下:

js
new CommonsChunkPlugin({
  // 从 common 和 base 两个现成的 Chunk 中提取公共的部分
  chunks: ['common', 'base'],
  // 把公共的部分放到 base 中
  name: 'base'
})
new CommonsChunkPlugin({
  // 从 common 和 base 两个现成的 Chunk 中提取公共的部分
  chunks: ['common', 'base'],
  // 把公共的部分放到 base 中
  name: 'base'
})

由于 common 和 base 公共的部分就是 base ⽬前已经包含的部分,所以这样配置后 common 将会变⼩,⽽ base 将保持不变。 以上都配置好后重新执⾏构建,你将会得到四个⽂件,它们分别是:

  • base.js:所有⽹⻚都依赖的基础库组成的代码;
  • common.js:⽹⻚A、B都需要的,但⼜不在 base.js ⽂件中出现过的代码;
  • a.js:⽹⻚ A 单独需要的代码;
  • b.js:⽹⻚ B 单独需要的代码;

为了让⽹⻚正常运⾏,以⽹⻚ A 为例,你需要在其 HTML 中按照以下顺序引⼊以下⽂件才能让⽹⻚正常运⾏:

html
<script src="base.js"></script>
<script src="common.js"></script>
<script src="a.js"></script>
<script src="base.js"></script>
<script src="common.js"></script>
<script src="a.js"></script>

以上就完成了提取公共代码需要的所有步骤。

针对 CSS 资源,以上理论和⽅法同样有效,也可以对 CSS ⽂件做同样的优化。

以上⽅法可能会出现 common.js 中没有代码的情况,原因是去掉基础运⾏库外很难再找到所有⻚⾯都会⽤上的模块。 在出现这种情况时,可以采取以下做法之⼀:

  • CommonsChunkPlugin 提供⼀个选项 minChunks ,表示⽂件要被提取出来时需要在指定的Chunks 中最⼩出现最⼩次数。 假如 minChunks=2、chunks=['a','b','c','d'] ,任何⼀个⽂件只要在 ['a','b','c','d'] 中任意两个以上的 Chunk 中都出现过,这个⽂件就会被提取出来。 可以根据⾃⼰的需求去调整 minChunks 的值, minChunks 越⼩越多的⽂件会被提取到 common.js 中去,但这也会导致部分⻚⾯加载的不相关的资源越多; minChunks 越⼤越少的⽂件会被提取到 common.js 中去,但这会导致 common.js 变⼩、效果变弱;
  • 根据各个⻚⾯之间的相关性选取其中的部分⻚⾯⽤ CommonsChunkPlugin 去提取这部分被选出的⻚⾯的公共部分,⽽不是提取所有⻚⾯的公共部分,⽽且这样的操作可以叠加多次。 这样做的效果会很好,但缺点是配置复杂,需要根据⻚⾯之间的关系去思考如何配置,该⽅法不通⽤;

分割代码按需加载

为什么需要按需加载

随着互联⽹的发展,⼀个⽹⻚需要承载的功能越来越多。 对于采⽤单⻚应⽤作为前端架构的⽹站来说,会⾯临着⼀个⽹⻚需要加载的代码量很⼤的问题,因为许多功能都集中的做到了⼀个 HTML ⾥。 这会导致⽹⻚加载缓慢、交互卡顿,⽤户体验将⾮常糟糕。

导致这个问题的根本原因在于⼀次性的加载所有功能对应的代码,但其实⽤户每⼀阶段只可能使⽤其中⼀部分功能。 所以解决以上问题的⽅法就是⽤户当前需要⽤什么功能就只加载这个功能对应的代码,也就是所谓的按需加载。

如何使⽤按需加载

在给单⻚应⽤做按需加载优化时,⼀般采⽤以下原则:

  • 把整个⽹站划分成⼀个个⼩功能,再按照每个功能的相关程度把它们分成⼏类;
  • 把每⼀类合并为⼀个 Chunk,按需加载对应的 Chunk;
  • 对于⽤户⾸次打开你的⽹站时需要看到的画⾯所对应的功能,不要对它们做按需加载,⽽是放到执⾏⼊⼝所在的 Chunk 中,以降低⽤户能感知的⽹⻚加载时间;
  • 对于个别依赖⼤量代码的功能点,例如依赖 Chart.js 去画图表、依赖 flv.js 去播放视频的功能点,可再对其进⾏按需加载;

被分割出去的代码的加载需要⼀定的时机去触发,也就是当⽤户操作到了或者即将操作到对应的功能时再去加载对应的代码。 被分割出去的代码的加载时机需要开发者⾃⼰去根据⽹⻚的需求去衡量和确定。

由于被分割出去进⾏按需加载的代码在加载的过程中也需要耗时,所以要预判好⽤户接下来可能会进⾏的操作,并提前加载好对应的代码,从⽽让⽤户感知不到⽹络加载时间。

⽤ Webpack 实现按需加载

Webpack 内置了强⼤的分割代码的功能去实现按需加载,实现起来⾮常简单。 举个例⼦,现在需要做这样⼀个进⾏了按需加载优化的⽹⻚:

  • ⽹⻚⾸次加载时只加载 main.js ⽂件,⽹⻚会展示⼀个按钮, main.js ⽂件中只包含监听按钮事件和加载按需加载的代码。
  • 当按钮被点击时才去加载被分割出去的 show.js ⽂件,加载成功后再执⾏ show.js ⾥的函数。

其中 main.js ⽂件内容如下:

js
window.document.getElementById('btn')
  .addEventListener('click', () => {
  // 当按钮被点击后才去加载 show.js ⽂件,⽂件加载成功后执⾏⽂件导出的函数
  import(/* webpackChunkName: "show" */ './show').then((show) => {
    show('Webpack');
  });
});
window.document.getElementById('btn')
  .addEventListener('click', () => {
  // 当按钮被点击后才去加载 show.js ⽂件,⽂件加载成功后执⾏⽂件导出的函数
  import(/* webpackChunkName: "show" */ './show').then((show) => {
    show('Webpack');
  });
});

show.js ⽂件内容如下:

js
module.exports = function (content) {
  console.log('Hello ' + content);
};
module.exports = function (content) {
  console.log('Hello ' + content);
};

代码中最关键的⼀句是 import(/* webpackChunkName: "show" */ './show') ,Webpack内置了对 import(*) 语句的⽀持,当 Webpack 遇到了类似的语句时会这样处理:

  • 以 ./show.js 为⼊⼝新⽣成⼀个 Chunk;
  • 当代码执⾏到 import 所在语句时才会去加载由 Chunk 对应⽣成的⽂件;
  • import 返回⼀个 Promise,当⽂件加载成功时可以在 Promise 的 then ⽅法中获取到 show.js 导出的内容;

在使⽤ import() 分割代码后,你的浏览器并且要⽀持 Promise API 才能让代码正常运⾏, 因为 import() 返回⼀个 Promise,它依赖 Promise。 对于不原⽣⽀持 Promise 的浏览器,可以注⼊Promise polyfill。

/* webpackChunkName: "show" */ 的含义是为动态⽣成的 Chunk 赋予⼀个名称,以⽅便我们追踪和调试代码。 如果不指定动态⽣成的 Chunk 的名称,默认名称将会是 [id].js。 /* webpackChunkName: "show" */ 是在 Webpack3 中引⼊的新特性,在 Webpack3 之前是⽆法为动态⽣成的Chunk 赋予名称的。

为了正确的输出在 /* webpackChunkName: "show" */ 中配置的 ChunkName,还需要配置下Webpack,配置如下:

js
module.exports = {
  // JS 执⾏⼊⼝⽂件
  entry: {
    main: './main.js',
  },
  output: {
  // 为从 entry 中配置⽣成的 Chunk 配置输出⽂件的名称
    filename: '[name].js',
    // 为动态加载的 Chunk 配置输出⽂件的名称
    chunkFilename: '[name].js',
  },
};
module.exports = {
  // JS 执⾏⼊⼝⽂件
  entry: {
    main: './main.js',
  },
  output: {
  // 为从 entry 中配置⽣成的 Chunk 配置输出⽂件的名称
    filename: '[name].js',
    // 为动态加载的 Chunk 配置输出⽂件的名称
    chunkFilename: '[name].js',
  },
};

其中最关键的⼀⾏是 chunkFilename: '[name].js' ,它专⻔指定动态⽣成的 Chunk 在输出时的⽂件名称。 如果没有这⾏,分割出的代码的⽂件名称将会是 [id].js。

Prepack

认识 Prepack

在前⾯的优化⽅法中提到了代码压缩和分块,这些都是在⽹络加载层⾯的优化,除此之外还可以优化代码在运⾏时的效率,Prepack 就是为此⽽⽣。

Prepack 由 Facebook 开源,它采⽤较为激进的⽅法:在保持运⾏结果⼀致的情况下,改变源代码的运⾏逻辑,输出性能更⾼的 JavaScript 代码。 实际上 Prepack 就是⼀个部分求值器,编译代码时提前将计算结果放到编译后的代码中,⽽不是在代码运⾏时才去求值。

以如下源码为例:

js
import React, { Component } from 'react';
import { renderToString } from 'react-dom/server';

function hello(name) {
  return `hello ${name}`;
}
class Button extends Component {
  render() {
    return hello(this.props.name);
  }
}
console.log(renderToString(<Button name='webpack'/>));
import React, { Component } from 'react';
import { renderToString } from 'react-dom/server';

function hello(name) {
  return `hello ${name}`;
}
class Button extends Component {
  render() {
    return hello(this.props.name);
  }
}
console.log(renderToString(<Button name='webpack'/>));

被 Prepack 转化后竟然直接输出如下:

js
console.log("hello webpack");
console.log("hello webpack");

可以看出 Prepack 通过在编译阶段预先执⾏了源码得到执⾏结果,再直接把运⾏结果输出来以提升性能。 Prepack 的⼯作原理和流程⼤致如下:

  1. 通过 Babel 把 JavaScript 源码解析成抽象语法树(AST),以⽅便更细粒度地分析源码;
  2. Prepack 实现了⼀个 JavaScript 解释器,⽤于执⾏源码。借助这个解释器 Prepack 才能掌握源码

具体是如何执⾏的,并把执⾏过程中的结果返回到输出中;

从表⾯上看去这似乎⾮常美好,但实际上 Prepack 还不够成熟与完善。Prepack ⽬前还处于初期的开发阶段,局限性也很⼤,例如:

  • 不能识别 DOM API 和 部分 Node.js API,如果源码中有调⽤依赖运⾏环境的 API 就会导致 Prepack 报错;
  • 存在优化后的代码性能反⽽更低的情况;
  • 存在优化后的代码⽂件尺⼨⼤⼤增加的情况;

总之,现在把 Prepack ⽤于线上环境还为时过早。

接⼊ Webpack

Prepack 需要在 Webpack 输出最终的代码之前,对这些代码进⾏优化,就像 UglifyJS 那样。 因此需要通过新接⼊⼀个插件来为 Webpack 接⼊ Prepack,幸运的是社区中已经有⼈做好了这个插件:prepack-webpack-plugin

接⼊该插件⾮常简单,相关配置代码如下:

js
const PrepackWebpackPlugin = require('prepack-webpack-plugin').default;

module.exports = {
  plugins: [
    new PrepackWebpackPlugin(),
  ],
};
const PrepackWebpackPlugin = require('prepack-webpack-plugin').default;

module.exports = {
  plugins: [
    new PrepackWebpackPlugin(),
  ],
};

重新执⾏构建你就会看到输出的被 Prepack 优化后的代码。

开启 Scope Hoisting

Scope Hoisting 可以让 Webpack 打包出来的代码⽂件更⼩、运⾏的更快, 它⼜译作 "作⽤域提升",是在 Webpack3 中新推出的功能。

认识 Scope Hoisting

让我们先来看看在没有 Scope Hoisting 之前 Webpack 的打包⽅式。 假如现在有两个⽂件分别是 util.js :

js
export default 'Hello,Webpack';
export default 'Hello,Webpack';

和⼊⼝⽂件 main.js :

js
import str from './util.js';
console.log(str);
import str from './util.js';
console.log(str);

以上源码⽤ Webpack 打包后输出中的部分代码如下:

js
[
  (function (module, __webpack_exports__, __webpack_require__) {
    var __WEBPACK_IMPORTED_MODULE_0__util_js__ = __webpack_require__(1);
    console.log(__WEBPACK_IMPORTED_MODULE_0__util_js__["a"]);
  }),
  (function (module, __webpack_exports__, __webpack_require__) {
    __webpack_exports__["a"] = ('Hello,Webpack');
  })
]
[
  (function (module, __webpack_exports__, __webpack_require__) {
    var __WEBPACK_IMPORTED_MODULE_0__util_js__ = __webpack_require__(1);
    console.log(__WEBPACK_IMPORTED_MODULE_0__util_js__["a"]);
  }),
  (function (module, __webpack_exports__, __webpack_require__) {
    __webpack_exports__["a"] = ('Hello,Webpack');
  })
]

在开启 Scope Hoisting 后,同样的源码输出的部分代码如下:

js
[
  (function (module, __webpack_exports__, __webpack_require__) {
    var util = ('Hello,Webpack');
    console.log(util);
  })
]
[
  (function (module, __webpack_exports__, __webpack_require__) {
    var util = ('Hello,Webpack');
    console.log(util);
  })
]

从中可以看出开启 Scope Hoisting 后,函数申明由两个变成了⼀个, util.js 中定义的内容被直接注⼊到了 main.js 对应的模块中。 这样做的好处是:

  • 代码体积更⼩,因为函数申明语句会产⽣⼤量代码;
  • 代码在运⾏时因为创建的函数作⽤域更少了,内存开销也随之变⼩;

Scope Hoisting 的实现原理其实很简单:分析出模块之间的依赖关系,尽可能的把打散的模块合并到⼀个函数中去,但前提是不能造成代码冗余。 因此只有那些被引⽤了⼀次的模块才能被合并。

由于 Scope Hoisting 需要分析出模块之间的依赖关系,因此源码必须采⽤ ES6 模块化语句,不然它将⽆法⽣效。 原因和 Tree shaking 类似。

使⽤ Scope Hoisting

要在 Webpack 中使⽤ Scope Hoisting ⾮常简单,因为这是 Webpack 内置的功能,只需要配置⼀个插件,相关代码如下:

js
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');

module.exports = {
  plugins: [
    // 开启 Scope Hoisting
    new ModuleConcatenationPlugin(),
  ],
};
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');

module.exports = {
  plugins: [
    // 开启 Scope Hoisting
    new ModuleConcatenationPlugin(),
  ],
};

同时,考虑到 Scope Hoisting 依赖源码需采⽤ ES6 模块化语法,还需要配置 mainFields 。

js
module.exports = {
  resolve: {
    // 针对 Npm 中的第三⽅模块优先采⽤ jsnext:main 中指向的 ES6 模块化语法的⽂件
    mainFields: ['jsnext:main', 'browser', 'main']
  },
};
module.exports = {
  resolve: {
    // 针对 Npm 中的第三⽅模块优先采⽤ jsnext:main 中指向的 ES6 模块化语法的⽂件
    mainFields: ['jsnext:main', 'browser', 'main']
  },
};

对于采⽤了⾮ ES6 模块化语法的代码,Webpack 会降级处理不使⽤ Scope Hoisting 优化,为了知道Webpack 对哪些代码做了降级处理, 可以在启动 Webpack 时带上 --display-optimization-bailout 参数,这样在输出⽇志中就会包含类似如下的⽇志:

bash
[0] ./main.js + 1 modules 80 bytes {0} [built]
 ModuleConcatenation bailout: Module is not an ECMAScript module
[0] ./main.js + 1 modules 80 bytes {0} [built]
 ModuleConcatenation bailout: Module is not an ECMAScript module

其中的 ModuleConcatenation bailout 告诉了你哪个⽂件因为什么原因导致了降级处理。 也就是说要开启 Scope Hoisting 并发挥最⼤作⽤的配置如下:

js
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');

module.exports = {
  resolve: {
    // 针对 Npm 中的第三⽅模块优先采⽤ jsnext:main 中指向的 ES6 模块化语法的⽂件
    mainFields: ['jsnext:main', 'browser', 'main'],
  },
  plugins: [
    // 开启 Scope Hoisting
    new ModuleConcatenationPlugin(),
  ],
};
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');

module.exports = {
  resolve: {
    // 针对 Npm 中的第三⽅模块优先采⽤ jsnext:main 中指向的 ES6 模块化语法的⽂件
    mainFields: ['jsnext:main', 'browser', 'main'],
  },
  plugins: [
    // 开启 Scope Hoisting
    new ModuleConcatenationPlugin(),
  ],
};

输出分析

我们需要对输出结果做分析,以决定下⼀步的优化⽅向。 最直接的分析⽅法就是去阅读 Webpack 输出的代码,但由于 Webpack 输出的代码可读性⾮常差⽽且⽂件⾮常⼤,这让人⾮常头疼。 为了更简单直观的分析输出结果,社区中出现了许多可视化的分析⼯具。 这些⼯具以图形的⽅式把结果更加直观的展示出来,让我们快速看到问题所在。 接下来我们去使⽤这些⼯具。

在启动 Webpack 时,⽀持两个参数,分别是:

  • --profile:记录下构建过程中的耗时信息;
  • --json:以 JSON 的格式输出构建结果,最后只输出⼀个 .json ⽂件,这个⽂件中包括所有构建相关的信息;

在启动 Webpack 时带上以上两个参数,启动命令如下 webpack --profile --json > stats.json ,会发现项⽬中多出了⼀个 stats.json ⽂件。 这个 stats.json ⽂件是给后⾯介绍的可视化分析⼯具使⽤的。

webpack --profile --json 会输出字符串形式的 JSON, > stats.json 是 UNIX/Linux系统中的管道命令、含义是把webpack --profile --json 输出的内容通过管道输出到stats.json ⽂件中。

官⽅的可视化分析⼯具

Webpack 官⽅提供了⼀个可视化分析⼯具 Webpack Analyse,它是⼀个在线 Web 应⽤。 打开 Webpack Analyse 链接的⽹⻚后,就会看到⼀个弹窗提示要求上传 JSON ⽂件,也就是需要上传上⾯讲到的 stats.json ⽂件,如图:

Alt text

Webpack Analyse 不会把选择的 stats.json ⽂件发达到服务器,⽽是在浏览器本地解析,因此不⽤担⼼⾃⼰的代码为此⽽泄露。 选择⽂件后,⻢上就能如下的效果图:

Alt text

它分为了六⼤板块,分别是:

  • Modules:展示所有的模块,每个模块对应⼀个⽂件。并且还包含所有模块之间的依赖关系图、模块路径、模块ID、模块所属 Chunk、模块⼤⼩;
  • Chunks:展示所有的代码块,⼀个代码块中包含多个模块。并且还包含代码块的ID、名称、⼤⼩、每个代码块包含的模块数量,以及代码块之间的依赖关系图;
  • Assets:展示所有输出的⽂件资源,包括 .js、.css、图⽚等。并且还包括⽂件名称、⼤⼩、该⽂件来⾃哪个代码块;
  • Warnings:展示构建过程中出现的所有警告信息;
  • Errors:展示构建过程中出现的所有错误信息;
  • Hints:展示处理每个模块的过程中的耗时。

点击 Modules,查看模块信息,效果图类似如下:

Alt text

由于依赖了⼤量第三⽅模块,⽂件数量⼤,导致模块之间的依赖关系图太密集⽽⽆法看清,但可以进⼀步放⼤查看。 点击 Chunks,查看代码块信息,效果图如下:

Alt text

由代码块之间的依赖关系图可以看出两个⻚⾯级的代码块 login 和 index 依赖提取出来的公共代码块 common。

点击 Assets,查看输出的⽂件资源,效果图如下:

Alt text

点击 Hints,查看输出过程中的耗时分布,效果图如下:

Alt text

从 Hints 可以看出每个⽂件在处理过程的开始时间和结束时间,从⽽可以找出是哪个⽂件导致构建缓慢。

webpack-bundle-analyzer

webpack-bundle-analyzer 是另⼀个可视化分析⼯具, 它虽然没有官⽅那样有那么多功能,但⽐官⽅的要更加直观。 先来看下它的效果图:

Alt text

我们能清楚的知道:

  • 打包出的⽂件中都包含了什么;
  • 每个⽂件的尺⼨在总体中的占⽐,⼀眼看出哪些⽂件尺⼨⼤;
  • 模块之间的包含关系;
  • 每个⽂件的 Gzip 后的⼤⼩;

接⼊ webpack-bundle-analyzer 的⽅法很简单,步骤如下:

  1. 安装 webpack-bundle-analyzer 到全局,执⾏命令 npm i -g webpack-bundle-analyzer ;
  2. 按照上⾯提到的⽅法⽣成 stats.json ⽂件;
  3. 在项⽬根⽬录中执⾏ webpack-bundle-analyzer 后,浏览器会打开对应⽹⻚看到以上效果。

优化总结

从开发体验 和 输出质量两个⻆度讲解了如何优化项⽬中的 Webpack 配置,这些优化的⽅法都是来⾃项⽬实战中的经验积累。 虽然每⼀⼩节都是⼀个个独⽴的优化⽅法,但是有些优化⽅法并不冲突可以相互组合,以达到最佳的效果。 以下将给出是结合了本章所有优化⽅法的实例项⽬,由于构建速度和输出质量不能兼得,按照开发环境和线上环境为该项⽬配置了两份⽂件,分别如下:

侧重优化开发体验

webpack-dev.config.js

js
const path = require('path');
const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
const { AutoWebPlugin } = require('web-webpack-plugin');
const HappyPack = require('happypack');
// ⾃动寻找 pages ⽬录下的所有⽬录,把每⼀个⽬录看成⼀个单⻚应⽤
const autoWebPlugin = new AutoWebPlugin('./src/pages', {
  // HTML 模版⽂件所在的⽂件路径
  template: './template.html',
  // 提取出所有⻚⾯公共的代码
  commonsChunk: {
    // 提取出公共代码 Chunk 的名称
    name: 'common',
  },
});
module.exports = {
  // AutoWebPlugin 会找为寻找到的所有单⻚应⽤,⽣成对应的⼊⼝配置,
  // autoWebPlugin.entry ⽅法可以获取到⽣成⼊⼝配置
  entry: autoWebPlugin.entry({
    // 这⾥可以加⼊你额外需要的 Chunk ⼊⼝
    base: './src/base.js',
  }),
  output: {
    filename: '[name].js',
  },
  resolve: {
    // 使⽤绝对路径指明第三⽅模块存放的位置,以减少搜索步骤
    // 其中 __dirname 表示当前⼯作⽬录,也就是项⽬根⽬录
    modules: [path.resolve(__dirname, 'node_modules')],
    // 针对 Npm 中的第三⽅模块优先采⽤ jsnext:main 中指向的 ES6 模块化语法的⽂件,使⽤ Tree Shaking 优化
    // 只采⽤ main 字段作为⼊⼝⽂件描述字段,以减少搜索步骤
    mainFields: ['jsnext:main', 'main'],
  },
  module: {
    rules: [
      {
        // 如果项⽬源码中只有 js ⽂件就不要写成 /\.jsx?$/,提升正则表达式性能
        test: /\.js$/,
        // 使⽤ HappyPack 加速构建
        use: ['happypack/loader?id=babel'],
        // 只对项⽬根⽬录下的 src ⽬录中的⽂件采⽤ babel-loader
        include: path.resolve(__dirname, 'src'),
      },
      {
        test: /\.js$/,
        use: ['happypack/loader?id=ui-component'],
        include: path.resolve(__dirname, 'src'),
      },
      {
        // 增加对 CSS ⽂件的⽀持
        test: /\.css$/,
        use: ['happypack/loader?id=css'],
      },
    ],
  },
  plugins: [
    autoWebPlugin,
    // 使⽤ HappyPack 加速构建
    new HappyPack({
      id: 'babel',
      // babel-loader ⽀持缓存转换出的结果,通过 cacheDirectory 选项开启
      loaders: ['babel-loader?cacheDirectory'],
    }),
    new HappyPack({
      // UI 组件加载拆分
      id: 'ui-component',
      loaders: [{
        loader: 'ui-component-loader',
        options: {
          lib: 'antd',
          style: 'style/index.css',
          camel2: '-',
        },
      }],
    }),
    new HappyPack({
      id: 'css',
      // 如何处理 .css ⽂件,⽤法和 Loader 配置中⼀样
      loaders: ['style-loader', 'css-loader'],
    }),
    // 提取公共代码
    new CommonsChunkPlugin({
      // 从 common 和 base 两个现成的 Chunk 中提取公共的部分
      chunks: ['common', 'base'],
      // 把公共的部分放到 base 中
      name: 'base',
    }),
  ],
  watchOptions: {
    // 使⽤⾃动刷新:不监听的 node_modules ⽬录下的⽂件
    ignored: /node_modules/,
  },
};
const path = require('path');
const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
const { AutoWebPlugin } = require('web-webpack-plugin');
const HappyPack = require('happypack');
// ⾃动寻找 pages ⽬录下的所有⽬录,把每⼀个⽬录看成⼀个单⻚应⽤
const autoWebPlugin = new AutoWebPlugin('./src/pages', {
  // HTML 模版⽂件所在的⽂件路径
  template: './template.html',
  // 提取出所有⻚⾯公共的代码
  commonsChunk: {
    // 提取出公共代码 Chunk 的名称
    name: 'common',
  },
});
module.exports = {
  // AutoWebPlugin 会找为寻找到的所有单⻚应⽤,⽣成对应的⼊⼝配置,
  // autoWebPlugin.entry ⽅法可以获取到⽣成⼊⼝配置
  entry: autoWebPlugin.entry({
    // 这⾥可以加⼊你额外需要的 Chunk ⼊⼝
    base: './src/base.js',
  }),
  output: {
    filename: '[name].js',
  },
  resolve: {
    // 使⽤绝对路径指明第三⽅模块存放的位置,以减少搜索步骤
    // 其中 __dirname 表示当前⼯作⽬录,也就是项⽬根⽬录
    modules: [path.resolve(__dirname, 'node_modules')],
    // 针对 Npm 中的第三⽅模块优先采⽤ jsnext:main 中指向的 ES6 模块化语法的⽂件,使⽤ Tree Shaking 优化
    // 只采⽤ main 字段作为⼊⼝⽂件描述字段,以减少搜索步骤
    mainFields: ['jsnext:main', 'main'],
  },
  module: {
    rules: [
      {
        // 如果项⽬源码中只有 js ⽂件就不要写成 /\.jsx?$/,提升正则表达式性能
        test: /\.js$/,
        // 使⽤ HappyPack 加速构建
        use: ['happypack/loader?id=babel'],
        // 只对项⽬根⽬录下的 src ⽬录中的⽂件采⽤ babel-loader
        include: path.resolve(__dirname, 'src'),
      },
      {
        test: /\.js$/,
        use: ['happypack/loader?id=ui-component'],
        include: path.resolve(__dirname, 'src'),
      },
      {
        // 增加对 CSS ⽂件的⽀持
        test: /\.css$/,
        use: ['happypack/loader?id=css'],
      },
    ],
  },
  plugins: [
    autoWebPlugin,
    // 使⽤ HappyPack 加速构建
    new HappyPack({
      id: 'babel',
      // babel-loader ⽀持缓存转换出的结果,通过 cacheDirectory 选项开启
      loaders: ['babel-loader?cacheDirectory'],
    }),
    new HappyPack({
      // UI 组件加载拆分
      id: 'ui-component',
      loaders: [{
        loader: 'ui-component-loader',
        options: {
          lib: 'antd',
          style: 'style/index.css',
          camel2: '-',
        },
      }],
    }),
    new HappyPack({
      id: 'css',
      // 如何处理 .css ⽂件,⽤法和 Loader 配置中⼀样
      loaders: ['style-loader', 'css-loader'],
    }),
    // 提取公共代码
    new CommonsChunkPlugin({
      // 从 common 和 base 两个现成的 Chunk 中提取公共的部分
      chunks: ['common', 'base'],
      // 把公共的部分放到 base 中
      name: 'base',
    }),
  ],
  watchOptions: {
    // 使⽤⾃动刷新:不监听的 node_modules ⽬录下的⽂件
    ignored: /node_modules/,
  },
};

侧重优化输出质量

webpack-prod.config.js

js
const path = require('path');
const DefinePlugin = require('webpack/lib/DefinePlugin');
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');
const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const { AutoWebPlugin } = require('web-webpack-plugin');
const HappyPack = require('happypack');
const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');
// ⾃动寻找 pages ⽬录下的所有⽬录,把每⼀个⽬录看成⼀个单⻚应⽤
const autoWebPlugin = new AutoWebPlugin('./src/pages', {
  // HTML 模版⽂件所在的⽂件路径
  template: './template.html',
  // 提取出所有⻚⾯公共的代码
  commonsChunk: {
    // 提取出公共代码 Chunk 的名称
    name: 'common',
  },
  // 指定存放 CSS ⽂件的 CDN ⽬录 URL
  stylePublicPath: '//css.cdn.com/id/',
});
module.exports = {
  // AutoWebPlugin 会找为寻找到的所有单⻚应⽤,⽣成对应的⼊⼝配置,
  // autoWebPlugin.entry ⽅法可以获取到⽣成⼊⼝配置
  entry: autoWebPlugin.entry({
    // 这⾥可以加⼊你额外需要的 Chunk ⼊⼝
    base: './src/base.js',
  }),
  output: {
    // 给输出的⽂件名称加上 Hash 值
    filename: '[name]_[chunkhash:8].js',
    path: path.resolve(__dirname, './dist'),
    // 指定存放 JavaScript ⽂件的 CDN ⽬录 URL
    publicPath: '//js.cdn.com/id/',
  },
  resolve: {
    // 使⽤绝对路径指明第三⽅模块存放的位置,以减少搜索步骤
    // 其中 __dirname 表示当前⼯作⽬录,也就是项⽬根⽬录
    modules: [path.resolve(__dirname, 'node_modules')],
    // 只采⽤ main 字段作为⼊⼝⽂件描述字段,以减少搜索步骤
    mainFields: ['jsnext:main', 'main'],
  },
  module: {
    rules: [
      {
        // 如果项⽬源码中只有 js ⽂件就不要写成 /\.jsx?$/,提升正则表达式性能
        test: /\.js$/,
        // 使⽤ HappyPack 加速构建
        use: ['happypack/loader?id=babel'],
        // 只对项⽬根⽬录下的 src ⽬录中的⽂件采⽤ babel-loader
        include: path.resolve(__dirname, 'src'),
      },
      {
        test: /\.js$/,
        use: ['happypack/loader?id=ui-component'],
        include: path.resolve(__dirname, 'src'),
      },
      {
        // 增加对 CSS ⽂件的⽀持
        test: /\.css$/,
        // 提取出 Chunk 中的 CSS 代码到单独的⽂件中
        use: ExtractTextPlugin.extract({
          use: ['happypack/loader?id=css'],
          // 指定存放 CSS 中导⼊的资源(例如图⽚)的 CDN ⽬录 URL
          publicPath: '//img.cdn.com/id/',
        }),
      },
    ],
  },
  plugins: [
    autoWebPlugin,
    // 开启ScopeHoisting
    new ModuleConcatenationPlugin(),
    // 使⽤HappyPack
    new HappyPack({
      // ⽤唯⼀的标识符 id 来代表当前的 HappyPack 是⽤来处理⼀类特定的⽂件
      id: 'babel',
      // babel-loader ⽀持缓存转换出的结果,通过 cacheDirectory 选项开启
      loaders: ['babel-loader?cacheDirectory'],
    }),
    new HappyPack({
      // UI 组件加载拆分
      id: 'ui-component',
      loaders: [{
        loader: 'ui-component-loader',
        options: {
          lib: 'antd',
          style: 'style/index.css',
          camel2: '-',
        },
      }],
    }),
    new HappyPack({
      id: 'css',
      // 如何处理 .css ⽂件,⽤法和 Loader 配置中⼀样
      // 通过 minimize 选项压缩 CSS 代码
      loaders: ['css-loader?minimize'],
    }),
    new ExtractTextPlugin({
      // 给输出的 CSS ⽂件名称加上 Hash 值
      filename: '[name]_[contenthash:8].css',
    }),
    // 提取公共代码
    new CommonsChunkPlugin({
      // 从 common 和 base 两个现成的 Chunk 中提取公共的部分
      chunks: ['common', 'base'],
      // 把公共的部分放到 base 中
      name: 'base',
    }),
    new DefinePlugin({
      // 定义 NODE_ENV 环境变量为 production 去除 react 代码中的开发时才需要的部分
      'process.env': {
        NODE_ENV: JSON.stringify('production'),
      },
    }),
    // 使⽤ ParallelUglifyPlugin 并⾏压缩输出的 JS 代码
    new ParallelUglifyPlugin({
      // 传递给 UglifyJS 的参数
      uglifyJS: {
        output: {
          // 最紧凑的输出
          beautify: false,
          // 删除所有的注释
          comments: false,
        },
        compress: {
          // 在UglifyJs删除没有⽤到的代码时不输出警告
          warnings: false,
          // 删除所有的 `console` 语句,可以兼容ie浏览器
          drop_console: true,
          // 内嵌定义了但是只⽤到⼀次的变量
          collapse_vars: true,
          // 提取出出现多次但是没有定义成变量去引⽤的静态值
          reduce_vars: true,
        },
      },
    }),
  ],
};
const path = require('path');
const DefinePlugin = require('webpack/lib/DefinePlugin');
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');
const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const { AutoWebPlugin } = require('web-webpack-plugin');
const HappyPack = require('happypack');
const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');
// ⾃动寻找 pages ⽬录下的所有⽬录,把每⼀个⽬录看成⼀个单⻚应⽤
const autoWebPlugin = new AutoWebPlugin('./src/pages', {
  // HTML 模版⽂件所在的⽂件路径
  template: './template.html',
  // 提取出所有⻚⾯公共的代码
  commonsChunk: {
    // 提取出公共代码 Chunk 的名称
    name: 'common',
  },
  // 指定存放 CSS ⽂件的 CDN ⽬录 URL
  stylePublicPath: '//css.cdn.com/id/',
});
module.exports = {
  // AutoWebPlugin 会找为寻找到的所有单⻚应⽤,⽣成对应的⼊⼝配置,
  // autoWebPlugin.entry ⽅法可以获取到⽣成⼊⼝配置
  entry: autoWebPlugin.entry({
    // 这⾥可以加⼊你额外需要的 Chunk ⼊⼝
    base: './src/base.js',
  }),
  output: {
    // 给输出的⽂件名称加上 Hash 值
    filename: '[name]_[chunkhash:8].js',
    path: path.resolve(__dirname, './dist'),
    // 指定存放 JavaScript ⽂件的 CDN ⽬录 URL
    publicPath: '//js.cdn.com/id/',
  },
  resolve: {
    // 使⽤绝对路径指明第三⽅模块存放的位置,以减少搜索步骤
    // 其中 __dirname 表示当前⼯作⽬录,也就是项⽬根⽬录
    modules: [path.resolve(__dirname, 'node_modules')],
    // 只采⽤ main 字段作为⼊⼝⽂件描述字段,以减少搜索步骤
    mainFields: ['jsnext:main', 'main'],
  },
  module: {
    rules: [
      {
        // 如果项⽬源码中只有 js ⽂件就不要写成 /\.jsx?$/,提升正则表达式性能
        test: /\.js$/,
        // 使⽤ HappyPack 加速构建
        use: ['happypack/loader?id=babel'],
        // 只对项⽬根⽬录下的 src ⽬录中的⽂件采⽤ babel-loader
        include: path.resolve(__dirname, 'src'),
      },
      {
        test: /\.js$/,
        use: ['happypack/loader?id=ui-component'],
        include: path.resolve(__dirname, 'src'),
      },
      {
        // 增加对 CSS ⽂件的⽀持
        test: /\.css$/,
        // 提取出 Chunk 中的 CSS 代码到单独的⽂件中
        use: ExtractTextPlugin.extract({
          use: ['happypack/loader?id=css'],
          // 指定存放 CSS 中导⼊的资源(例如图⽚)的 CDN ⽬录 URL
          publicPath: '//img.cdn.com/id/',
        }),
      },
    ],
  },
  plugins: [
    autoWebPlugin,
    // 开启ScopeHoisting
    new ModuleConcatenationPlugin(),
    // 使⽤HappyPack
    new HappyPack({
      // ⽤唯⼀的标识符 id 来代表当前的 HappyPack 是⽤来处理⼀类特定的⽂件
      id: 'babel',
      // babel-loader ⽀持缓存转换出的结果,通过 cacheDirectory 选项开启
      loaders: ['babel-loader?cacheDirectory'],
    }),
    new HappyPack({
      // UI 组件加载拆分
      id: 'ui-component',
      loaders: [{
        loader: 'ui-component-loader',
        options: {
          lib: 'antd',
          style: 'style/index.css',
          camel2: '-',
        },
      }],
    }),
    new HappyPack({
      id: 'css',
      // 如何处理 .css ⽂件,⽤法和 Loader 配置中⼀样
      // 通过 minimize 选项压缩 CSS 代码
      loaders: ['css-loader?minimize'],
    }),
    new ExtractTextPlugin({
      // 给输出的 CSS ⽂件名称加上 Hash 值
      filename: '[name]_[contenthash:8].css',
    }),
    // 提取公共代码
    new CommonsChunkPlugin({
      // 从 common 和 base 两个现成的 Chunk 中提取公共的部分
      chunks: ['common', 'base'],
      // 把公共的部分放到 base 中
      name: 'base',
    }),
    new DefinePlugin({
      // 定义 NODE_ENV 环境变量为 production 去除 react 代码中的开发时才需要的部分
      'process.env': {
        NODE_ENV: JSON.stringify('production'),
      },
    }),
    // 使⽤ ParallelUglifyPlugin 并⾏压缩输出的 JS 代码
    new ParallelUglifyPlugin({
      // 传递给 UglifyJS 的参数
      uglifyJS: {
        output: {
          // 最紧凑的输出
          beautify: false,
          // 删除所有的注释
          comments: false,
        },
        compress: {
          // 在UglifyJs删除没有⽤到的代码时不输出警告
          warnings: false,
          // 删除所有的 `console` 语句,可以兼容ie浏览器
          drop_console: true,
          // 内嵌定义了但是只⽤到⼀次的变量
          collapse_vars: true,
          // 提取出出现多次但是没有定义成变量去引⽤的静态值
          reduce_vars: true,
        },
      },
    }),
  ],
};

相关链接